# coding=utf-8 """Tests de l'extension « Relief Pattern Fill » (pytest).""" import os import re import xml.etree.ElementTree as ET import pytest import math from relief_core import (CELL_EDGES, clip_segment, fill_rings, parse_color, pattern_segments, point_in_rings, polylines_to_d, rotate_point) HERE = os.path.dirname(os.path.abspath(__file__)) SHAPES = os.path.join(HERE, "tests", "data", "shapes.svg") SCALED = os.path.join(HERE, "tests", "data", "scaled.svg") # -------------------------------------------------------------------------- # 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)) 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" SQUARE = [(0, 0), (100, 0), (100, 100), (0, 100)] HOLED = [SQUARE, [(25, 25), (75, 25), (75, 75), (25, 75)]] def length(segment): (x1, y1), (x2, y2) = segment return math.hypot(x2 - x1, y2 - y1) def key(segment): (x1, y1), (x2, y2) = segment return round(x1, 3), round(y1, 3), round(x2, 3), round(y2, 3) def test_cell_edges_are_valid_lattice_edges(): assert len(set(CELL_EDGES)) == len(CELL_EDGES) == 39 for i, j, di, dj in CELL_EDGES: assert (i + j) % 2 == 0 assert (di, dj) in ((0, 2), (1, 1), (1, -1)) def test_pattern_directions_and_periodicity(): """Segments verticaux ou a +/-30 degres ; motif invariant par translation.""" edge = 10.0 segments = pattern_segments((0, 0, 200, 200), edge) for (x1, y1), (x2, y2) in segments: angle = round(math.degrees(math.atan2(y2 - y1, x2 - x1))) % 180 assert angle in (30, 90, 150) keys = {key(s) for s in segments} inner = [k for k in keys if all(40 < k[n] < 160 for n in (0, 2)) and all(40 < k[n] < 150 for n in (1, 3))] assert inner for x1, y1, x2, y2 in inner: assert (x1, round(y1 + 5 * edge, 3), x2, round(y2 + 5 * edge, 3)) in keys def test_pattern_merges_collinear_edges(): """Deux aretes bout a bout sur la meme droite forment un seul segment.""" edge = 10.0 segments = pattern_segments((0, 0, 200, 200), edge) assert max(length(s) for s in segments) > 1.5 * edge at_node = {} for p, q in segments: for a, b in ((p, q), (q, p)): at_node.setdefault((round(a[0], 3), round(a[1], 3)), []).append(b) for point, others in at_node.items(): for k, a in enumerate(others): for b in others[k + 1:]: cross = ((a[0] - point[0]) * (b[1] - point[1]) - (a[1] - point[1]) * (b[0] - point[0])) dot = ((a[0] - point[0]) * (b[0] - point[0]) + (a[1] - point[1]) * (b[1] - point[1])) assert not (abs(cross) < 1e-6 and dot < 0) def test_pattern_rejects_bad_edge(): with pytest.raises(ValueError): pattern_segments((0, 0, 10, 10), 0) def test_pattern_too_many_segments(): with pytest.raises(OverflowError): pattern_segments((0, 0, 1e6, 1e6), 1.0) def test_point_in_rings_with_hole(): assert point_in_rings((10, 10), HOLED) assert not point_in_rings((50, 50), HOLED) assert not point_in_rings((150, 50), HOLED) def test_clip_segment_square(): pieces = clip_segment((-50, 50), (150, 50), [SQUARE]) assert len(pieces) == 1 (x1, y1), (x2, y2) = pieces[0] assert (round(x1, 6), round(x2, 6), y1, y2) == (0, 100, 50, 50) assert clip_segment((200, 0), (300, 100), [SQUARE]) == [] def test_clip_segment_hole_splits_segment(): pieces = clip_segment((-10, 50), (110, 50), HOLED) xs = sorted(round(p[0], 6) for piece in pieces for p in piece) assert xs == [0, 25, 75, 100] @pytest.mark.parametrize("angle", [0, 17, 90]) def test_fill_stays_inside_shape(angle): triangle = [(0, 0), (120, 10), (40, 90)] segments = fill_rings([triangle], 6.0, angle, (50, 30)) assert len(segments) > 50 for a, b in segments: mid = ((a[0] + b[0]) / 2, (a[1] + b[1]) / 2) assert point_in_rings(mid, [triangle]) def test_fill_avoids_hole(): segments = fill_rings(HOLED, 4.0) assert segments for a, b in segments: mid = ((a[0] + b[0]) / 2, (a[1] + b[1]) / 2) assert not (25 < mid[0] < 75 and 25 < mid[1] < 75) def test_fill_rotation_turns_directions(): segments = fill_rings([SQUARE], 6.0, 90.0, (50, 50)) angles = {round(math.degrees(math.atan2(b[1] - a[1], b[0] - a[0]))) % 180 for a, b in segments} assert angles == {0, 60, 120} def test_fill_anchor_is_a_node(): """Meme motif, relativement a l'ancre, quand la forme est translatee.""" base = {key(s) for s in fill_rings([SQUARE], 6.0, 0.0, (50, 50))} shifted = [[(x + 7, y + 3) for x, y in SQUARE]] moved = {key(((a[0] - 7, a[1] - 3), (b[0] - 7, b[1] - 3))) for a, b in fill_rings(shifted, 6.0, 0.0, (57, 53))} assert base == moved def test_fill_degenerate_inputs(): assert fill_rings([], 5.0) == [] assert fill_rings([[(0, 0), (1, 1)]], 5.0) == [] fill_rings([SQUARE], 500.0) # arete enorme : pas d'exception def test_rotate_point_clockwise_on_screen(): x, y = rotate_point((1, 0), 90) assert (round(x, 6), round(y, 6)) == (0, 1) # -------------------------------------------------------------------------- # Bout en bout (necessite inkex) # -------------------------------------------------------------------------- def run_extension(tmp_path, *args): pytest.importorskip("inkex") from relief_fill import ReliefFill out = tmp_path / "out.svg" ReliefFill().run([*args, "--output={}".format(out), SHAPES]) return out.read_text(encoding="utf-8") def test_end_to_end_default(tmp_path): svg = run_extension(tmp_path, "--id=rect1") assert "Relief fill" in svg assert 'id="rect1"' in svg assert len(list(generated_paths(svg))) == 1 # un seul chemin pour un rectangle def test_end_to_end_non_standard_document_scale(tmp_path): """Page de 400 mm pour 538,72 unites : svg.unittouu() s'y tromperait.""" pytest.importorskip("inkex") from relief_fill import ReliefFill out = tmp_path / "out.svg" ReliefFill().run(["--id=rect1", "--output={}".format(out), SCALED]) width = re.search(r"stroke-width:([\d.]+)", out.read_text(encoding="utf-8")) assert float(width.group(1)) == pytest.approx(0.3 * 538.72053 / 400, abs=0.001) 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) def generated_paths(svg_text): """Points (x, y) de chaque chemin du groupe « Relief fill ».""" root = ET.fromstring(svg_text) for group in root.iter("{http://www.w3.org/2000/svg}g"): if "Relief fill" in group.attrib.values(): for path in group.iter("{http://www.w3.org/2000/svg}path"): nums = [float(n) for n in re.findall(r"-?\d+\.?\d*", path.get("d"))] yield [(nums[k], nums[k + 1]) for k in range(0, len(nums) - 1, 2)] @pytest.mark.parametrize("shape,box", [ ("rect1", (10, 10, 60, 50)), ("circle1", (78, 8, 122, 52)), ("path1", (120, 0, 200, 60)), ]) def test_end_to_end_stays_in_bounding_box(tmp_path, shape, box): svg = run_extension(tmp_path, "--id=" + shape, "--edge=3") points = [p for pts in generated_paths(svg) for p in pts] assert points for x, y in points: assert box[0] - 0.01 <= x <= box[2] + 0.01 assert box[1] - 0.01 <= y <= box[3] + 0.01 def test_end_to_end_hole_stays_empty(tmp_path): svg = run_extension(tmp_path, "--id=path2", "--edge=3") count = 0 for pts in generated_paths(svg): for k in range(0, len(pts) - 1, 2): count += 1 mid = ((pts[k][0] + pts[k + 1][0]) / 2, (pts[k][1] + pts[k + 1][1]) / 2) assert not (36 < mid[0] < 64 and 81 < mid[1] < 99) assert count > 20 def test_end_to_end_group_with_transform(tmp_path): """Groupe transforme : le motif suit l'ellipse, decalee de (100, 65).""" svg = run_extension(tmp_path, "--id=group1", "--edge=3") points = [p for pts in generated_paths(svg) for p in pts] assert points for x, y in points: assert 105 - 0.01 <= x <= 185 + 0.01 assert 70 - 0.01 <= y <= 110 + 0.01 def test_end_to_end_outline_option(tmp_path): svg = run_extension(tmp_path, "--id=rect1", "--draw_outline=true") assert len(list(generated_paths(svg))) == 2 # contour + trait def test_end_to_end_too_many_segments(tmp_path, capsys): pytest.importorskip("inkex") from relief_fill import ReliefFill ReliefFill().run(["--id=rect1", "--edge=0.01", "--output={}".format(tmp_path / "out.svg"), SHAPES]) assert "too many lines" in capsys.readouterr().err # -------------------------------------------------------------------------- # 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 "Relief 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, et inversement.""" root = ET.parse(os.path.join(HERE, "relief_fill.inx")).getroot() params = {elem.get("name") for elem in root.iter() if elem.tag.rsplit("}", 1)[-1] == "param"} with open(os.path.join(HERE, "relief_fill.py"), encoding="utf-8") as handle: arguments = set(re.findall(r'add_argument\("--(\w+)"', handle.read())) assert params == arguments 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, "relief_fill.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