431 lines
17 KiB
Python
431 lines
17 KiB
Python
# coding=utf-8
|
|
"""Tests de l'extension « Wavy Ribbon Pattern Fill » (pytest)."""
|
|
|
|
import math
|
|
import os
|
|
import re
|
|
import xml.etree.ElementTree as ET
|
|
|
|
import pytest
|
|
|
|
from wavy_ribbon_fill_core import (
|
|
EdgeIndex, RibbonPattern, fill_polylines, join_polylines, parse_color,
|
|
polylines_to_d, rings_bounds, rotate_points, subtract_intervals, wave_profile)
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
SHAPES = os.path.join(HERE, "tests", "data", "shapes.svg")
|
|
|
|
SQUARE = [(0.0, 0.0), (200.0, 0.0), (200.0, 200.0), (0.0, 200.0)]
|
|
|
|
|
|
def total_length(polylines):
|
|
return sum(math.hypot(b[0] - a[0], b[1] - a[1])
|
|
for polyline in polylines for a, b in zip(polyline, polyline[1:]))
|
|
|
|
|
|
def segments(polylines, digits=6):
|
|
"""Segments normalises, pour comparer deux traces sans tenir compte du
|
|
sens ni du decoupage en polylignes."""
|
|
found = set()
|
|
for polyline in polylines:
|
|
for a, b in zip(polyline, polyline[1:]):
|
|
a = (round(a[0], digits), round(a[1], digits))
|
|
b = (round(b[0], digits), round(b[1], digits))
|
|
found.add((min(a, b), max(a, b)))
|
|
return found
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Noyau (sans inkex)
|
|
# --------------------------------------------------------------------------
|
|
|
|
def test_parse_color_inkscape_integer():
|
|
assert parse_color("3014898687") == ("#b3b3b3", 1.0)
|
|
assert parse_color(str(0xFF000080)) == ("#ff0000", round(0x80 / 255, 4))
|
|
assert parse_color("255") == ("#000000", 1.0)
|
|
|
|
|
|
def test_parse_color_hex_and_invalid():
|
|
assert parse_color("#123456") == ("#123456", 1.0)
|
|
assert parse_color("#abc") == ("#aabbcc", 1.0)
|
|
assert parse_color("pas une couleur") == ("#b3b3b3", 1.0)
|
|
|
|
|
|
def test_polylines_to_d():
|
|
d = polylines_to_d([[(0, 0), (1, 2)], [(3, 4), (5, 6)]], precision=1)
|
|
assert d == "M 0.0,0.0 L 1.0,2.0 M 3.0,4.0 L 5.0,6.0"
|
|
|
|
|
|
def test_rotate_points_clockwise_on_screen():
|
|
(x, y), = rotate_points([(1.0, 0.0)], 90)
|
|
assert (round(x, 9), round(y, 9)) == (0.0, 1.0) # y vers le bas
|
|
(x, y), = rotate_points([(3.0, 2.0)], 180, center=(2.0, 2.0))
|
|
assert (round(x, 9), round(y, 9)) == (1.0, 2.0)
|
|
|
|
|
|
def test_rings_bounds():
|
|
assert rings_bounds([SQUARE, [(-5.0, 10.0), (3.0, 250.0)]]) == (-5.0, 0.0, 200.0, 250.0)
|
|
assert rings_bounds([]) is None
|
|
|
|
|
|
def test_wave_profile_ends_and_monotony():
|
|
profile = wave_profile(12.0, 14.0, 5.6, 0.01)
|
|
assert profile[0] == (0.0, 0.0) and profile[-1] == (12.0, 14.0)
|
|
for (x0, v0), (x1, v1) in zip(profile, profile[1:]):
|
|
assert x1 > x0 and v1 > v0
|
|
# Tangente verticale aux deux bouts : le premier pas monte bien plus qu'il n'avance.
|
|
(x0, v0), (x1, v1) = profile[0], profile[1]
|
|
assert (v1 - v0) > 5 * (x1 - x0)
|
|
# Symetrie centrale du S.
|
|
for (x, v), (mx, mv) in zip(profile, reversed(profile)):
|
|
assert abs(x + mx - 12.0) < 1e-9 and abs(v + mv - 14.0) < 1e-9
|
|
|
|
|
|
def test_wave_profile_radius_limits():
|
|
assert wave_profile(12.0, 14.0, 0.0, 0.01) == [(0.0, 0.0), (12.0, 14.0)]
|
|
# Rayon borne a la moitie de la hauteur : le profil reste une fonction de x.
|
|
profile = wave_profile(12.0, 6.0, 50.0, 0.01)
|
|
assert profile[-1] == (12.0, 6.0)
|
|
assert all(b[0] > a[0] and b[1] >= a[1] for a, b in zip(profile, profile[1:]))
|
|
|
|
|
|
def test_wave_profile_stays_on_its_arcs():
|
|
radius = 5.6
|
|
profile = wave_profile(12.0, 14.0, radius, 0.001)
|
|
first = [p for p in profile if p[1] < 2.0]
|
|
assert len(first) > 3
|
|
for x, v in first:
|
|
assert abs(math.hypot(x - radius, v) - radius) < 1e-9
|
|
|
|
|
|
def test_subtract_intervals():
|
|
assert subtract_intervals([(0, 10)], [(2, 3), (5, 20)]) == [(0, 2), (3, 5)]
|
|
assert subtract_intervals([(0, 10)], [(-5, 0), (10, 12)]) == [(0, 10)]
|
|
assert subtract_intervals([(0, 10)], [(-1, 11)]) == []
|
|
assert subtract_intervals([], [(0, 1)]) == []
|
|
|
|
|
|
def test_edge_index_clip_inside_and_outside():
|
|
index = EdgeIndex([[(0, 0), (10, 0), (10, 10), (0, 10)]])
|
|
line = [(-5.0, 5.0), (15.0, 5.0)]
|
|
assert index.clip(line) == [[(0.0, 5.0), (10.0, 5.0)]]
|
|
assert index.clip(line, keep_inside=False) == [[(-5.0, 5.0), (0.0, 5.0)],
|
|
[(10.0, 5.0), (15.0, 5.0)]]
|
|
assert index.clip([(20.0, 20.0), (30.0, 25.0)]) == []
|
|
|
|
|
|
def test_edge_index_even_odd_hole():
|
|
index = EdgeIndex([[(0, 0), (30, 0), (30, 30), (0, 30)],
|
|
[(10, 10), (20, 10), (20, 20), (10, 20)]])
|
|
assert index.inside(5, 15) and not index.inside(15, 15) and not index.inside(40, 15)
|
|
pieces = index.clip([(-5.0, 15.0), (35.0, 15.0)])
|
|
assert pieces == [[(0.0, 15.0), (10.0, 15.0)], [(20.0, 15.0), (30.0, 15.0)]]
|
|
|
|
|
|
def test_join_polylines():
|
|
joined = join_polylines([[(0.0, 0.0), (0.0, 5.0)], [(0.0, 10.0), (0.0, 5.0)],
|
|
[(0.0, 10.0), (4.0, 12.0)], [(9.0, 9.0), (8.0, 8.0)]])
|
|
# Deux traits soudes bout a bout, point aligne retire ; le sens est libre.
|
|
assert sorted(min(line, line[::-1]) for line in joined) == [
|
|
[(0.0, 0.0), (0.0, 10.0), (4.0, 12.0)], [(8.0, 8.0), (9.0, 9.0)]]
|
|
|
|
|
|
def test_join_polylines_leaves_three_way_junctions():
|
|
lines = [[(0.0, 0.0), (5.0, 5.0)], [(5.0, 5.0), (9.0, 5.0)], [(5.0, 5.0), (5.0, 9.0)]]
|
|
assert len(join_polylines(lines)) == 3
|
|
|
|
|
|
def test_pattern_rejects_bad_settings():
|
|
for kwargs in ({"spacing": 0}, {"spacing": 2, "lines": 1}, {"spacing": 2, "shift": 0},
|
|
{"spacing": 2, "height": 0}, {"spacing": 2, "stagger": -1},
|
|
{"spacing": 2, "columns": 0}, {"spacing": 2, "rows": 0}):
|
|
with pytest.raises(ValueError):
|
|
RibbonPattern(**kwargs)
|
|
|
|
|
|
def test_fill_empty_input():
|
|
assert fill_polylines([], 2.0) == []
|
|
assert fill_polylines([[(0, 0), (1, 1)]], 2.0) == []
|
|
|
|
|
|
def test_fill_stays_inside_shape():
|
|
polylines = fill_polylines([SQUARE], 2.0, origin=(100.0, 100.0))
|
|
assert polylines
|
|
for polyline in polylines:
|
|
assert len(polyline) >= 2
|
|
for x, y in polyline:
|
|
assert -1e-9 <= x <= 200 + 1e-9 and -1e-9 <= y <= 200 + 1e-9
|
|
|
|
|
|
def test_fill_leaves_holes_empty():
|
|
hole = [(60.0, 60.0), (140.0, 60.0), (140.0, 140.0), (60.0, 140.0)]
|
|
polylines = fill_polylines([SQUARE, hole], 2.0)
|
|
assert polylines
|
|
for polyline in polylines:
|
|
for a, b in zip(polyline, polyline[1:]):
|
|
mx, my = (a[0] + b[0]) / 2, (a[1] + b[1]) / 2
|
|
assert not (60 + 1e-6 < mx < 140 - 1e-6 and 60 + 1e-6 < my < 140 - 1e-6)
|
|
# Le trou retire de la matiere.
|
|
assert total_length(polylines) < total_length(fill_polylines([SQUARE], 2.0))
|
|
|
|
|
|
def test_background_lines_are_on_the_grid():
|
|
spacing, origin = 2.0, (3.0, 50.0)
|
|
polylines = fill_polylines([SQUARE], spacing, origin=origin)
|
|
vertical = [(a, b) for polyline in polylines for a, b in zip(polyline, polyline[1:])
|
|
if a[0] == b[0]]
|
|
assert len(vertical) > 50
|
|
for a, _b in vertical:
|
|
steps = (a[0] - origin[0]) / spacing
|
|
assert abs(steps - round(steps)) < 1e-9
|
|
|
|
|
|
def test_hidden_interval_matches_ribbon_edges():
|
|
pattern = RibbonPattern(2.0)
|
|
piece = (0, 0, 0, 10.0, 10.0 + pattern.region_height, False)
|
|
assert pattern.region_height == pytest.approx(2.0 * (7.0 + 4 * 1.0))
|
|
assert pattern.hidden_interval(piece, -1) is None
|
|
assert pattern.hidden_interval(piece, pattern.span + 1) is None
|
|
# Au milieu du ruban, le fond est masque sur toute la hauteur de l'ondulation.
|
|
assert pattern.hidden_interval(piece, 5) == pytest.approx((10.0 + 14.0 - pattern._rise(10.0),
|
|
32.0 - pattern._rise(2.0)))
|
|
# Rive du premier trait : masquee sous le bas de son onde seulement.
|
|
assert pattern.hidden_interval(piece, 0) == pytest.approx((24.0, 32.0))
|
|
# Le miroir echange les rives.
|
|
mirrored = piece[:5] + (True,)
|
|
assert pattern.hidden_interval(mirrored, pattern.span) == pytest.approx((24.0, 32.0))
|
|
|
|
|
|
def test_ribbon_lines_are_translated_copies():
|
|
pattern = RibbonPattern(2.0, stagger=1.5)
|
|
piece = (0, 0, 0, 0.0, pattern.region_height, False)
|
|
curves = pattern.piece_curves(piece)
|
|
assert len(curves) == 5
|
|
# Sans les prolongements verticaux, chaque trait = le premier + k (pas, decalage).
|
|
wave = len(pattern.profile)
|
|
bodies = [curve[1:1 + wave] if k < 4 else curve[:wave] for k, curve in enumerate(curves)]
|
|
for k, curve in enumerate(curves):
|
|
assert len(bodies[k]) == wave
|
|
for (x, y), (x0, y0) in zip(bodies[k], bodies[0]):
|
|
assert x == pytest.approx(x0 + k * 2.0) and y == pytest.approx(y0 + k * 3.0)
|
|
# Les deux bouts tombent sur des traits du fond, aux bords du ruban.
|
|
assert curve[0] == (pattern.grid_x(k), pattern.region_height)
|
|
assert curve[-1] == (pattern.grid_x(k + 6), 0.0)
|
|
|
|
|
|
def test_mirrored_ribbon_is_the_mirror_image():
|
|
pattern = RibbonPattern(2.0)
|
|
plain = pattern.piece_curves((0, 0, 0, 0.0, pattern.region_height, False))
|
|
mirrored = pattern.piece_curves((1, 0, 0, 0.0, pattern.region_height, True))
|
|
width = pattern.span * 2.0
|
|
for curve, other in zip(plain, mirrored):
|
|
assert [(pytest.approx(width - x), y) for x, y in curve] == other
|
|
|
|
|
|
def test_pattern_is_periodic():
|
|
spacing = 2.0
|
|
window = [(20.0, 20.0), (120.0, 20.0), (120.0, 110.0), (20.0, 110.0)]
|
|
dx, dy = 2 * 8 * spacing, 2 * 7.75 * spacing
|
|
moved = [(x + dx, y + dy) for x, y in window]
|
|
first = segments(fill_polylines([window], spacing))
|
|
second = segments([[(x - dx, y - dy) for x, y in polyline]
|
|
for polyline in fill_polylines([moved], spacing)])
|
|
assert first == second and len(first) > 500
|
|
|
|
|
|
def test_default_ribbons_do_not_overlap():
|
|
"""Avec les reglages par defaut, aucun ruban n'en recouvre un autre : le
|
|
decoupage entre rubans ne retire rien."""
|
|
pattern = RibbonPattern(2.0)
|
|
reference = segments(pattern.fill([SQUARE]))
|
|
pattern.pieces_overlap = lambda: False
|
|
assert segments(pattern.fill([SQUARE])) == reference
|
|
|
|
|
|
def test_overlapping_ribbons_are_cut():
|
|
tight = RibbonPattern(2.0, rows=4.0)
|
|
cut = total_length(tight.fill([SQUARE]))
|
|
tight.pieces_overlap = lambda: False
|
|
assert cut < total_length(tight.fill([SQUARE])) - 1.0
|
|
|
|
|
|
def test_rotation_turns_the_whole_pattern():
|
|
circle = [(60 * math.cos(math.radians(a)), 60 * math.sin(math.radians(a)))
|
|
for a in range(360)]
|
|
plain = fill_polylines([circle], 2.0)
|
|
turned = fill_polylines([circle], 2.0, angle=30.0)
|
|
# Le cercle a 360 sommets est invariant par rotation de 30 degres.
|
|
assert segments(rotate_points(p, 30.0) for p in plain) == segments(turned)
|
|
|
|
|
|
def test_origin_moves_the_pattern():
|
|
here = segments(fill_polylines([SQUARE], 2.0, origin=(0.0, 0.0)))
|
|
there = segments(fill_polylines([SQUARE], 2.0, origin=(1.0, 0.0)))
|
|
assert here != there
|
|
|
|
|
|
def test_stagger_zero_and_straight_lines():
|
|
polylines = fill_polylines([SQUARE], 2.0, stagger=0.0, radius=0.0)
|
|
assert polylines
|
|
assert max(len(polyline) for polyline in polylines) < 20
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Bout en bout (necessite inkex)
|
|
# --------------------------------------------------------------------------
|
|
|
|
def run_extension(tmp_path, *args):
|
|
pytest.importorskip("inkex")
|
|
from wavy_ribbon_fill import WavyRibbonFill
|
|
out = tmp_path / "out.svg"
|
|
WavyRibbonFill().run([*args, "--output={}".format(out), SHAPES])
|
|
return out.read_text(encoding="utf-8")
|
|
|
|
|
|
def pattern_points(svg):
|
|
"""Points du premier chemin genere."""
|
|
d = re.search(r'<path[^>]*\bd="(M [^"]*)"[^>]*fill:none|<path[^>]*fill:none[^>]*\bd="(M [^"]*)"',
|
|
svg)
|
|
assert d, "chemin du motif introuvable"
|
|
data = d.group(1) or d.group(2)
|
|
return [(float(x), float(y)) for x, y in re.findall(r"(-?[\d.]+),(-?[\d.]+)", data)]
|
|
|
|
|
|
def test_end_to_end_default(tmp_path):
|
|
svg = run_extension(tmp_path, "--id=rect1")
|
|
assert "Wavy ribbon pattern" in svg
|
|
assert 'id="rect1"' in svg
|
|
assert "fill:none" in svg and "stroke:#000000" in svg
|
|
points = pattern_points(svg)
|
|
assert len(points) > 100
|
|
assert all(10 - 1e-3 <= x <= 60 + 1e-3 and 10 - 1e-3 <= y <= 50 + 1e-3 for x, y in points)
|
|
|
|
|
|
def test_end_to_end_removes_original(tmp_path):
|
|
svg = run_extension(tmp_path, "--id=rect1", "--keep_original=false")
|
|
assert 'id="rect1"' not in svg
|
|
assert "Wavy ribbon pattern" in svg
|
|
|
|
|
|
def test_end_to_end_color_and_width(tmp_path):
|
|
svg = run_extension(tmp_path, "--id=rect1", "--stroke_color={}".format(0x336699FF),
|
|
"--stroke_width=0.5")
|
|
assert re.search(r"stroke:#336699", svg)
|
|
assert re.search(r"stroke-width:0\.5\b", svg)
|
|
|
|
|
|
def test_end_to_end_curved_shapes(tmp_path):
|
|
svg = run_extension(tmp_path, "--id=circle1", "--id=path1")
|
|
assert svg.count("Wavy ribbon pattern") == 2
|
|
for x, y in pattern_points(svg):
|
|
assert math.hypot(x - 100, y - 30) <= 22 + 0.05
|
|
|
|
|
|
def test_end_to_end_hole_stays_empty(tmp_path):
|
|
svg = run_extension(tmp_path, "--id=path2", "--spacing=1")
|
|
points = pattern_points(svg)
|
|
assert points
|
|
assert not [p for p in points if 35.01 < p[0] < 64.99 and 80.01 < p[1] < 99.99]
|
|
|
|
|
|
def test_end_to_end_transformed_parent(tmp_path):
|
|
"""Forme dans un groupe deplace : motif en coordonnees absolues, le
|
|
groupe produit annule la transformation du parent."""
|
|
svg = run_extension(tmp_path, "--id=ellipse1", "--spacing=1")
|
|
assert re.search(r'transform="translate\(-100,? -65\)"', svg)
|
|
for x, y in pattern_points(svg):
|
|
assert ((x - 145) / 40.0) ** 2 + ((y - 90) / 20.0) ** 2 <= 1.01
|
|
|
|
|
|
def test_end_to_end_group_selection(tmp_path):
|
|
svg = run_extension(tmp_path, "--id=group1", "--spacing=1")
|
|
assert svg.count("Wavy ribbon pattern") == 1
|
|
|
|
|
|
def test_end_to_end_units(tmp_path):
|
|
millimetres = pattern_points(run_extension(tmp_path, "--id=rect1", "--spacing=5"))
|
|
centimetres = pattern_points(run_extension(tmp_path, "--id=rect1", "--spacing=0.5",
|
|
"--unit=cm"))
|
|
assert millimetres == centimetres
|
|
|
|
|
|
def test_end_to_end_no_selection(tmp_path, capsys):
|
|
"""Sans selection : un message, et le document n'est pas modifie."""
|
|
pytest.importorskip("inkex")
|
|
from wavy_ribbon_fill import WavyRibbonFill
|
|
out = tmp_path / "out.svg"
|
|
WavyRibbonFill().run(["--output={}".format(out), SHAPES])
|
|
assert "Select at least one shape." in capsys.readouterr().err
|
|
assert not out.exists() or "Wavy ribbon pattern" not in out.read_text(encoding="utf-8")
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Traductions
|
|
# --------------------------------------------------------------------------
|
|
|
|
def test_translations_up_to_date_and_complete():
|
|
"""Chaque texte du .inx et des .py a sa traduction dans chaque catalogue."""
|
|
import gettext
|
|
import i18n
|
|
|
|
msgids = [msgid for msgid, _refs in i18n.extract()]
|
|
assert "Wavy Ribbon Pattern Fill" in msgids
|
|
assert "mm" not in msgids # unites marquees translatable="no"
|
|
|
|
for language in i18n.LANGUAGES:
|
|
entries = i18n.read_po(i18n.po_path(language))
|
|
missing = [m for m in msgids if not entries.get(m, ("", False))[0]]
|
|
assert not missing, "{}.po incomplet : {}".format(language, missing)
|
|
|
|
catalog = gettext.translation(i18n.DOMAIN, i18n.LOCALE_DIR, [language])
|
|
for msgid in msgids:
|
|
assert catalog.gettext(msgid) == entries[msgid][0], \
|
|
"{} : .mo a recompiler (python i18n.py)".format(language)
|
|
|
|
|
|
def test_po_roundtrip(tmp_path):
|
|
import i18n
|
|
|
|
messages = [("Simple", ["a"]), ('Quote "x" and \\ back', ["a"]),
|
|
("Two\nlines", ["a"]), ("Tab\tend\n", ["a"])]
|
|
path = str(tmp_path / "xx.po")
|
|
i18n.write_po(path, "fr", messages,
|
|
{m: ("<" + m + ">", False) for m, _r in messages})
|
|
entries = i18n.read_po(path)
|
|
for msgid, _refs in messages:
|
|
assert entries[msgid] == ("<" + msgid + ">", False)
|
|
|
|
|
|
def test_inx_matches_arguments():
|
|
"""Chaque <param> du .inx a son add_argument, avec la meme valeur par defaut."""
|
|
root = ET.parse(os.path.join(HERE, "wavy_ribbon_fill.inx")).getroot()
|
|
params = {elem.get("name"): (elem.text or "").strip() for elem in root.iter()
|
|
if elem.tag.rsplit("}", 1)[-1] == "param"}
|
|
with open(os.path.join(HERE, "wavy_ribbon_fill.py"), encoding="utf-8") as handle:
|
|
source = handle.read()
|
|
arguments = dict(re.findall(r'add_argument\("--(\w+)".*?default=([^)]+)\)', source))
|
|
assert set(params) == set(arguments)
|
|
for name, default in arguments.items():
|
|
if params[name]: # notebook et listes : pas de valeur dans le texte
|
|
value = default.strip('"')
|
|
if value in ("True", "False"):
|
|
assert params[name] == value.lower()
|
|
else:
|
|
assert float(params[name]) == float(value), name
|
|
|
|
|
|
def test_inx_image_size_is_three_quarters_of_the_png():
|
|
"""Schema de l'onglet Help present, affiche aux 3/4 de sa taille."""
|
|
import struct
|
|
|
|
root = ET.parse(os.path.join(HERE, "wavy_ribbon_fill.inx")).getroot()
|
|
images = [elem for elem in root.iter() if elem.tag.rsplit("}", 1)[-1] == "image"]
|
|
assert images
|
|
for elem in images:
|
|
path = os.path.join(HERE, elem.text.strip())
|
|
assert os.path.isfile(path), elem.text
|
|
with open(path, "rb") as handle:
|
|
width, height = struct.unpack(">II", handle.read(24)[16:24])
|
|
assert int(elem.get("width")) == round(width * 0.75)
|
|
assert int(elem.get("height")) == round(height * 0.75)
|