# coding=utf-8 """Tests de l'extension « Living Hinge Fill » (pytest).""" import math import os import random import re import xml.etree.ElementTree as ET import pytest from living_hinge_core import (bounding_box, clear_intervals, hinge_slots, parse_color, polylines_to_d, random_spans, regular_spans, rotate, slot_length, slot_outline, slots_to_d) HERE = os.path.dirname(os.path.abspath(__file__)) SHAPES = os.path.join(HERE, "tests", "data", "shapes.svg") RECT = [[(0.0, 0.0), (60.0, 0.0), (60.0, 80.0), (0.0, 80.0)]] # Rectangle troue (pair-impair) HOLED = [RECT[0], [(20.0, 30.0), (40.0, 30.0), (40.0, 50.0), (20.0, 50.0)]] DIMS = dict(length=20.0, width=3.0, bridge=2.0, pitch=5.0) def circle(cx, cy, r, n=180): return [(cx + r * math.cos(2 * math.pi * k / n), cy + r * math.sin(2 * math.pi * k / n)) for k in range(n)] def distance_to_segment(p, a, b): ex, ey = b[0] - a[0], b[1] - a[1] norm = ex * ex + ey * ey t = 0.0 if norm == 0 else max(0.0, min(1.0, ((p[0] - a[0]) * ex + (p[1] - a[1]) * ey) / norm)) return math.hypot(p[0] - a[0] - t * ex, p[1] - a[1] - t * ey) def distance_to_boundary(p, rings): return min(distance_to_segment(p, ring[k], ring[(k + 1) % len(ring)]) for ring in rings for k in range(len(ring))) def is_inside(p, rings): inside = False for ring in rings: for k in range(len(ring)): (x1, y1), (x2, y2) = ring[k], ring[(k + 1) % len(ring)] if (y1 <= p[1]) != (y2 <= p[1]) and p[0] < x1 + (p[1] - y1) * (x2 - x1) / (y2 - y1): inside = not inside return inside # -------------------------------------------------------------------------- # Noyau (sans inkex) : utilitaires # -------------------------------------------------------------------------- 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_bounding_box(): assert bounding_box(HOLED) == (0.0, 0.0, 60.0, 80.0) assert bounding_box([]) is None def test_rotate_counter_clockwise_on_screen(): # y vers le bas : un point a droite du centre monte (y diminue). x, y = rotate((1.0, 0.0), 90) assert abs(x) < 1e-12 and abs(y + 1.0) < 1e-12 x, y = rotate((3.0, 2.0), 180, center=(2.0, 2.0)) assert abs(x - 1.0) < 1e-12 and abs(y - 2.0) < 1e-12 def test_clear_intervals_rectangle(): assert clear_intervals(RECT, 30.0) == [(0.0, 80.0)] (y0, y1), = clear_intervals(RECT, 30.0, 5.0) assert abs(y0 - 5.0) < 1e-6 and abs(y1 - 75.0) < 1e-6 # Trop pres du bord gauche, ou hors de la forme assert clear_intervals(RECT, 3.0, 5.0) == [] assert clear_intervals(RECT, 70.0) == [] def test_clear_intervals_hole_and_corner(): low, high = clear_intervals(HOLED, 30.0, 2.0) assert abs(low[1] - 28.0) < 1e-6 and abs(high[0] - 52.0) < 1e-6 # A cote du trou : la zone interdite s'arrondit autour de ses coins. (_y0, y1), (y2, _y3) = clear_intervals(HOLED, 18.0, 4.0) reach = math.sqrt(4.0 ** 2 - 2.0 ** 2) assert abs(y1 - (30.0 - reach)) < 1e-6 and abs(y2 - (50.0 + reach)) < 1e-6 # -------------------------------------------------------------------------- # Noyau (sans inkex) : motif # -------------------------------------------------------------------------- def test_slots_stay_inside_with_margin(): for rings in (RECT, HOLED, [circle(50, 50, 40), circle(50, 50, 10)]): for angle in (0.0, 30.0, 90.0): slots = hinge_slots(rings, margin=2.0, angle=angle, **DIMS) assert slots for slot in slots: for point in slot_outline(slot, 1.5): assert is_inside(point, rings) assert distance_to_boundary(point, rings) >= 2.0 - 1e-6 def test_slot_lengths_and_minimum(): slots = hinge_slots(RECT, margin=2.0, min_length=6.0, **DIMS) lengths = [slot_length(slot, 3.0) for slot in slots] assert max(lengths) <= 20.0 + 1e-9 assert min(lengths) >= 6.0 - 1e-9 assert any(abs(value - 20.0) < 1e-9 for value in lengths) # lumieres entieres assert any(value < 20.0 - 1e-6 for value in lengths) # et raccourcies # Un minimum plus haut ecarte des lumieres raccourcies, jamais les entieres. full_only = hinge_slots(RECT, margin=2.0, min_length=20.0, **DIMS) assert 0 < len(full_only) < len(slots) assert all(abs(slot_length(slot, 3.0) - 20.0) < 1e-9 for slot in full_only) def test_columns_pitch_bridge_and_stagger(): slots = hinge_slots(RECT, margin=2.0, **DIMS) columns = {} for (x0, y0), (x1, y1) in slots: assert x0 == x1 and y0 <= y1 # lumieres verticales columns.setdefault(round(x0, 6), []).append((y0 - 1.5, y1 + 1.5)) xs = sorted(columns) assert all(abs(b - a - 5.0) < 1e-9 for a, b in zip(xs, xs[1:])) assert 30.0 in xs # motif centre sur la forme assert xs[0] >= 3.5 and xs[-1] <= 56.5 # demi-largeur + marge for spans in columns.values(): spans.sort() for (_a, end), (start, _b) in zip(spans, spans[1:]): assert abs(start - end - 2.0) < 1e-9 # pont entre deux lumieres # Quinconce : decalage d'une demi-periode (22 / 2) entre colonnes voisines. even = {round(end % 22.0, 6) for _start, end in columns[30.0][:-1]} odd = {round(end % 22.0, 6) for _start, end in columns[35.0][:-1]} assert len(even) == 1 and len(odd) == 1 assert abs(abs(even.pop() - odd.pop()) - 11.0) < 1e-6 def test_stagger_zero_aligns_columns(): slots = hinge_slots(RECT, margin=2.0, stagger=0.0, **DIMS) spans = {} for (x0, y0), (_x1, y1) in slots: spans.setdefault(round(x0, 6), []).append((round(y0, 6), round(y1, 6))) assert len(spans) > 1 assert len(set(map(tuple, spans.values()))) == 1 def test_angle_90_gives_horizontal_slots(): slots = hinge_slots(RECT, margin=2.0, angle=90.0, **DIMS) assert slots for (x0, y0), (x1, y1) in slots: assert abs(y0 - y1) < 1e-9 and abs(x1 - x0) > 1.0 def test_hole_is_avoided(): slots = hinge_slots(HOLED, margin=2.0, **DIMS) for slot in slots: for x, y in slot_outline(slot, 1.5): assert not (18.0 + 1e-6 < x < 42.0 - 1e-6 and 28.0 + 1e-6 < y < 52.0 - 1e-6) assert len(slots) != len(hinge_slots(RECT, margin=2.0, **DIMS)) def test_regular_spans(): spans = regular_spans(0.0, 100.0, 5.0, 20.0, 2.0) assert all(size == 20.0 for _start, size in spans) starts = [start for start, _size in spans] assert 5.0 in starts and starts[0] + 20.0 > 0.0 and starts[-1] <= 100.0 assert all(abs(b - a - 22.0) < 1e-9 for a, b in zip(starts, starts[1:])) def test_random_spans_fill_exactly(): # Toutes sortes de hauteurs : la zone est remplie pile, d'un bout a l'autre. for k in range(200): low, high = 10.0, 10.0 + 25.0 + 1.37 * k spans = random_spans(low, high, 3.0, 20.0, 2.0, random.Random(k)) assert abs(spans[0][0] - low) < 1e-9 assert abs(spans[-1][0] + spans[-1][1] - high) < 1e-9 assert all(3.0 - 1e-9 <= size <= 20.0 + 1e-9 for _start, size in spans) for (start, size), (following, _s) in zip(spans, spans[1:]): assert abs(following - start - size - 2.0) < 1e-9 # pont constant # Reproductible, et different d'un tirage a l'autre. args = (0.0, 500.0, 3.0, 20.0, 2.0) assert random_spans(*args, random.Random(1)) == random_spans(*args, random.Random(1)) assert random_spans(*args, random.Random(1)) != random_spans(*args, random.Random(2)) def test_random_spans_tight_cases(): rng = random.Random(0) # Zone plus courte que la plus courte lumiere : une seule, qui la remplit. assert random_spans(0.0, 4.0, 6.0, 20.0, 2.0, rng) == [(0.0, 4.0)] # Zone d'une lumiere pile. assert random_spans(5.0, 15.0, 3.0, 20.0, 2.0, rng) == [(5.0, 10.0)] # Aucun compte ne tombe juste (bornes egales) : lumieres egales, zone remplie. spans = random_spans(0.0, 76.0, 20.0, 20.0, 2.0, rng) assert len({round(size, 9) for _start, size in spans}) == 1 assert spans[0][0] == 0.0 and abs(spans[-1][0] + spans[-1][1] - 76.0) < 1e-9 assert random_spans(3.0, 3.0, 3.0, 20.0, 2.0, rng) == [] def test_random_columns_start_and_end_at_margin(): # Dans un rectangle, toutes les colonnes commencent et finissent au meme niveau. slots = hinge_slots(RECT, margin=2.0, random_min=3.0, seed=4, **DIMS) columns = {} for (x0, y0), (_x1, y1) in slots: columns.setdefault(round(x0, 6), []).append((y0 - 1.5, y1 + 1.5)) assert len(columns) == 11 for spans in columns.values(): assert abs(min(a for a, _b in spans) - 2.0) < 1e-6 assert abs(max(b for _a, b in spans) - 78.0) < 1e-6 # Autour d'un trou aussi : chaque zone libre est remplie d'un bord a l'autre. slots = hinge_slots(HOLED, margin=2.0, random_min=3.0, seed=4, **DIMS) middle = sorted((y0 - 1.5, y1 + 1.5) for (x0, y0), (_x1, y1) in slots if x0 == 30.0) ends = [b for _a, b in middle] starts = [a for a, _b in middle] assert abs(starts[0] - 2.0) < 1e-6 and abs(ends[-1] - 78.0) < 1e-6 assert any(abs(b - 28.0) < 1e-6 for b in ends) assert any(abs(a - 52.0) < 1e-6 for a in starts) def test_random_lengths(): big = [[(0.0, 0.0), (200.0, 0.0), (200.0, 300.0), (0.0, 300.0)]] slots = hinge_slots(big, margin=2.0, random_min=3.0, seed=7, **DIMS) assert slots == hinge_slots(big, margin=2.0, random_min=3.0, seed=7, **DIMS) assert slots != hinge_slots(big, margin=2.0, random_min=3.0, seed=8, **DIMS) lengths = [slot_length(slot, 3.0) for slot in slots] assert min(lengths) >= 3.0 - 1e-9 and max(lengths) <= 20.0 + 1e-9 assert min(lengths) < 6.0 and max(lengths) > 17.0 # toute la plage sert assert len({round(value, 3) for value in lengths}) > len(lengths) / 2 # Pas des colonnes et pont inchanges, lumieres a la marge. columns = {} for (x0, y0), (x1, y1) in slots: assert x0 == x1 assert 3.5 - 1e-6 <= y0 and y1 <= 296.5 + 1e-6 columns.setdefault(round(x0, 6), []).append((y0 - 1.5, y1 + 1.5)) xs = sorted(columns) assert all(abs(b - a - 5.0) < 1e-9 for a, b in zip(xs, xs[1:])) for spans in columns.values(): spans.sort() for (_a, end), (start, _b) in zip(spans, spans[1:]): assert abs(start - end - 2.0) < 1e-9 # Les colonnes ne sont pas des copies les unes des autres. assert len({tuple(round(v, 3) for span in spans for v in span) for spans in columns.values()}) == len(columns) def test_random_min_is_clamped_and_respects_shape(): # Minimum sous la largeur : ramene a la largeur (trou rond au plus petit). slots = hinge_slots(HOLED, margin=2.0, random_min=0.0, seed=3, **DIMS) assert slots for slot in slots: assert slot_length(slot, 3.0) >= 3.0 - 1e-9 for point in slot_outline(slot, 1.5): assert is_inside(point, HOLED) assert distance_to_boundary(point, HOLED) >= 2.0 - 1e-6 # Minimum ramene a la longueur : plus de hasard, lumieres egales d'une marge a l'autre. slots = hinge_slots(RECT, margin=2.0, random_min=50.0, seed=3, **DIMS) assert slots and len({round(slot_length(slot, 3.0), 6) for slot in slots}) == 1 def test_full_slot_shorter_than_minimum_is_kept(): # min_length ne vise que les lumieres raccourcies par le bord. slots = hinge_slots(RECT, length=5.0, width=3.0, bridge=2.0, pitch=5.0, min_length=9.0) assert slots and all(abs(slot_length(slot, 3.0) - 5.0) < 1e-9 for slot in slots) def test_degenerate_inputs(): assert hinge_slots([], **DIMS) == [] assert hinge_slots([[(0, 0), (1, 1)]], **DIMS) == [] assert hinge_slots([[(0, 0), (4, 0), (4, 4), (0, 4)]], margin=2.0, **DIMS) == [] assert hinge_slots(RECT, length=2.0, width=3.0, bridge=2.0, pitch=5.0) == [] assert hinge_slots(RECT, length=20.0, width=0.0, bridge=2.0, pitch=5.0) == [] assert hinge_slots(RECT, length=20.0, width=3.0, bridge=2.0, pitch=0.0) == [] def test_slot_outline_is_at_radius_of_axis(): slot = ((10.0, 10.0), (10.0, 30.0)) outline = slot_outline(slot, 2.0, segments=8) assert all(abs(a - b) < 1e-9 for a, b in zip(outline[0], outline[-1])) for point in outline: assert abs(distance_to_segment(point, *slot) - 2.0) < 1e-9 ys = [y for _x, y in outline] assert abs(min(ys) - 8.0) < 1e-9 and abs(max(ys) - 32.0) < 1e-9 def test_slots_to_d(): d = slots_to_d([((10.0, 10.0), (10.0, 30.0))], 2.0, precision=1) assert d == ("M 8.0,10.0 L 8.0,30.0 A 2.0 2.0 0 0 0 12.0,30.0 " "L 12.0,10.0 A 2.0 2.0 0 0 0 8.0,10.0 Z") # Lumiere reduite a un cercle : deux arcs, pas de segment nul. circle_d = slots_to_d([((5.0, 5.0), (5.0, 5.0))], 1.0, precision=0) assert circle_d == "M 4,5 A 1 1 0 0 0 6,5 A 1 1 0 0 0 4,5 Z" assert slots_to_d([], 1.0) == "" assert slots_to_d([((0, 0), (0, 9)), ((5, 0), (5, 9))], 1.0).count("Z") == 2 # -------------------------------------------------------------------------- # Bout en bout (necessite inkex) # -------------------------------------------------------------------------- def run_extension(tmp_path, *args): pytest.importorskip("inkex") from living_hinge import LivingHinge out = tmp_path / "out.svg" if out.exists(): out.unlink() LivingHinge().run([*args, "--output={}".format(out), SHAPES]) # inkex n'ecrit rien quand le document n'a pas change (erreur utilisateur). return out.read_text(encoding="utf-8") if out.exists() else "" def generated_paths(svg): """Chemins ajoutes par l'extension (ceux du fichier d'exemple ont un id connu).""" root = ET.fromstring(svg) return [elem for elem in root.iter() if elem.tag.endswith("}path") and elem.get("id") not in ("path1", "path2")] def test_end_to_end_default(tmp_path): svg = run_extension(tmp_path, "--id=rect1") assert "Living hinge" in svg assert 'id="rect1"' in svg paths = generated_paths(svg) assert len(paths) == 1 # un seul chemin pour toutes les lumieres assert paths[0].get("d").count("Z") >= 10 assert "fill:none" in paths[0].get("style") def test_end_to_end_slots_inside_rect(tmp_path): svg = run_extension(tmp_path, "--id=rect1", "--margin=2") d = generated_paths(svg)[0].get("d") points = [(float(x), float(y)) for x, y in re.findall(r"(-?[\d.]+),(-?[\d.]+)", d)] assert points # rect1 : x 10..60, y 10..50 ; les raccords droite / arc restent a la marge # en x, et a marge + rayon en y (le bout arrondi depasse d'un rayon). assert all(12.0 - 1e-3 <= x <= 58.0 + 1e-3 for x, _y in points) assert all(13.5 - 1e-3 <= y <= 46.5 + 1e-3 for _x, y in points) def test_end_to_end_all_shapes_and_group(tmp_path): svg = run_extension(tmp_path, "--id=circle1", "--id=path1", "--id=path2", "--id=group1") assert svg.count("Living hinge") == 4 assert len(generated_paths(svg)) == 4 # Le groupe d'accueil neutralise la translation du groupe parent. assert re.search(r'transform="translate\(-100,? ?-65\)"', svg) def test_end_to_end_units_and_angle(tmp_path): svg = run_extension(tmp_path, "--id=rect1", "--unit=cm", "--slot_length=2", "--slot_width=0.3", "--bridge=0.2", "--pitch=0.5", "--margin=0.2", "--min_length=0.6", "--stroke_width=0.02", "--angle=90") assert "A 1.5000 1.5000" in svg assert re.search(r"stroke-width:0\.2\d*[;\"]", svg) def test_end_to_end_errors(tmp_path, capsys): svg = run_extension(tmp_path) # pas de selection assert "Living hinge" not in svg svg = run_extension(tmp_path, "--id=rect1", "--pitch=2") # pas <= largeur assert "Living hinge" not in svg svg = run_extension(tmp_path, "--id=rect1", "--slot_length=1") # longueur < largeur assert "Living hinge" not in svg svg = run_extension(tmp_path, "--id=rect1", "--margin=30") # rien ne tient assert "Living hinge" not in svg errors = capsys.readouterr().err assert "Select at least one shape" in errors assert "column pitch" in errors and "slot length" in errors and "too small" in errors def arc_starts(svg): """Debut de chaque lumiere du chemin genere (un « M x,y » par lumiere).""" return re.findall(r"M (-?[\d.]+,-?[\d.]+)", generated_paths(svg)[0].get("d")) def test_end_to_end_random(tmp_path): regular = run_extension(tmp_path, "--id=path2") first = run_extension(tmp_path, "--id=path2", "--random_lengths=true") again = run_extension(tmp_path, "--id=path2", "--random_lengths=true") other = run_extension(tmp_path, "--id=path2", "--random_lengths=true", "--seed=2") assert arc_starts(first) == arc_starts(again) # meme graine, meme motif assert arc_starts(first) != arc_starts(other) assert arc_starts(first) != arc_starts(regular) assert len(arc_starts(first)) > len(arc_starts(regular)) # lumieres plus courtes def test_end_to_end_random_error(tmp_path, capsys): svg = run_extension(tmp_path, "--id=rect1", "--random_lengths=true", "--random_min=25") assert "Living hinge" not in svg assert "shortest random slot" in capsys.readouterr().err 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 def test_end_to_end_color(tmp_path): svg = run_extension(tmp_path, "--id=rect1", "--stroke_color={}".format(0x336699FF)) assert re.search(r"stroke:#336699", svg) # -------------------------------------------------------------------------- # 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 "Living Hinge 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, memes noms et memes defauts.""" root = ET.parse(os.path.join(HERE, "living_hinge.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, "living_hinge.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 name in ("tab", "unit"): continue value = default.strip('"') if params[name] in ("true", "false"): assert value.lower() == params[name], name elif name == "stroke_color": assert value == params[name], name else: assert float(value) == float(params[name]), name def test_inx_images_exist(): """Les images de la boite de dialogue sont presentes (chemin relatif au .inx).""" root = ET.parse(os.path.join(HERE, "living_hinge.inx")).getroot() for elem in root.iter(): if elem.tag.rsplit("}", 1)[-1] == "image": assert os.path.isfile(os.path.join(HERE, elem.text.strip())), elem.text