# 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']*\bd="(M [^"]*)"[^>]*fill:none|]*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 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)