# coding=utf-8 """Tests de l'extension « Hatch Fill » (pytest).""" import math import os import re import xml.etree.ElementTree as ET import pytest import hachures_core as core from hachures_core import parse_color, polylines_to_d 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" def square(x0, y0, x1, y1): return [(x0, y0), (x1, y0), (x1, y1), (x0, y1)] def test_spacing_from_density(): assert core.spacing_from_density(100, 0.2) == pytest.approx(0.2) assert core.spacing_from_density(25, 0.2) == pytest.approx(0.8) assert core.spacing_from_density(25, 0.2, cross=True) == pytest.approx(1.6) for density, width in ((0, 0.2), (101, 0.2), (50, 0)): with pytest.raises(ValueError): core.spacing_from_density(density, width) @pytest.mark.parametrize("density", [10, 25, 50, 80]) @pytest.mark.parametrize("angle", [0, 30, 45, 90, -60]) def test_coverage_matches_density(density, angle): """Longueur x largeur / surface de la forme = taux demande.""" width = 0.2 spacing = core.spacing_from_density(density, width) lines = core.hatch([square(0, 0, 60, 40)], spacing, angle) assert core.cutting_length(lines) * width / (60 * 40) == pytest.approx(density / 100.0, rel=0.06) def test_lines_stay_inside_shape(): for a, b in core.hatch([square(10, 10, 50, 30)], 1.0, 33): for x, y in (a, b): assert 10 - 1e-6 <= x <= 50 + 1e-6 and 10 - 1e-6 <= y <= 30 + 1e-6 def test_hole_is_left_empty(): rings = [square(0, 0, 40, 40), square(10, 10, 30, 30)] lines = core.hatch(rings, 1.0, 0) area = 40 * 40 - 20 * 20 assert core.cutting_length(lines) * 1.0 / area == pytest.approx(1.0, rel=0.03) def test_vertical_hatching_zigzags(): """Traits verticaux : on monte sur le premier, on redescend sur le suivant.""" lines = core.hatch([square(0, 0, 10, 10)], 2.0, 90) assert len(lines) == 5 for a, b in zip(lines, lines[1:]): assert b[0][1] == pytest.approx(a[1][1]) # repart d'ou la tete est arrivee assert abs(b[0][0] - a[1][0]) == pytest.approx(2.0) # un pas plus loin assert (lines[0][1][1] - lines[0][0][1]) * (lines[1][1][1] - lines[1][0][1]) < 0 def test_serpentine_beats_naive_order(): """Le trajet optimise est tres inferieur a un balayage toujours dans le meme sens.""" ordered = core.hatch([square(0, 0, 100, 60)], 1.0, 0) naive = [[(0, 0.5 + k), (100, 0.5 + k)] for k in range(60)] assert core.travel_length(ordered) < core.travel_length(naive) / 20 assert core.travel_length(ordered) < 60 * 1.01 # un pas entre deux traits def test_all_segments_visited_exactly_once(): rings = [square(0, 0, 50, 50), square(15, 15, 35, 35)] lines = core.scan_lines(rings, 1.0, 0.5) ordered = core.order_lines(lines, 1.0, 0.5) assert len(ordered) == sum(len(v) for v in lines.values()) assert {(k, min(a, b), max(a, b)) for k, a, b in ordered} == \ {(k, a, b) for k, v in lines.items() for a, b in v} def test_concave_shape_short_travel(): """Forme en U : les deux branches sont remplies, sans grand saut.""" u_shape = [(0, 0), (10, 0), (10, 40), (20, 40), (20, 0), (30, 0), (30, 50), (0, 50)] lines = core.hatch([u_shape], 1.0, 0) area = 30 * 50 - 10 * 40 assert core.cutting_length(lines) / area == pytest.approx(1.0, rel=0.03) assert core.travel_length(lines) < core.cutting_length(lines) * 0.2 def test_cross_hatch_has_both_directions_and_same_coverage(): spacing = core.spacing_from_density(40, 0.25, cross=True) lines = core.hatch([square(0, 0, 60, 60)], spacing, 0, cross=True) horizontal = [ln for ln in lines if abs(ln[0][1] - ln[1][1]) < 1e-6] vertical = [ln for ln in lines if abs(ln[0][0] - ln[1][0]) < 1e-6] assert horizontal and vertical and len(horizontal) + len(vertical) == len(lines) assert core.cutting_length(lines) * 0.25 / 3600 == pytest.approx(0.40, rel=0.08) # la seconde passe demarre pres de la ou la premiere s'est arretee assert math.hypot(vertical[0][0][0] - horizontal[-1][1][0], vertical[0][0][1] - horizontal[-1][1][1]) < 2 * spacing def test_anchor_origin_aligns_lines_between_shapes(): for rings in ([square(3, 3, 40, 20)], [square(50, 7, 90, 31)]): for line in core.hatch(rings, 2.0, 0, anchor="origin"): assert line[0][1] / 2.0 == pytest.approx(round(line[0][1] / 2.0)) def test_anchor_shape_centers_pattern(): ys = sorted(ln[0][1] for ln in core.hatch([square(0, 0, 10, 10.6)], 2.0, 0)) assert ys[0] == pytest.approx(10.6 - ys[-1]) def test_empty_and_degenerate_input(): assert core.hatch([], 1.0) == [] assert core.hatch([[(0, 0), (1, 1)]], 1.0) == [] def test_too_many_lines(): with pytest.raises(core.TooManyLines): core.hatch([square(0, 0, 100, 100)], 0.001, 0) def test_flatten_cubic_subpaths(): def corner(x, y): return [(x, y), (x, y), (x, y)] nodes = [corner(0, 0), corner(10, 0), corner(10, 10), corner(0, 10), corner(0, 0)] assert core.flatten_cubic_subpaths([nodes], 0.1) == [[(0, 0), (10, 0), (10, 10), (0, 10)]] k = 0.5522847498 * 10 circle = [[(10, -k), (10, 0), (10, k)], [(k, 10), (0, 10), (-k, 10)], [(-10, k), (-10, 0), (-10, -k)], [(-k, -10), (0, -10), (k, -10)], [(10, -k), (10, 0), (10, k)]] ring = core.flatten_cubic_subpaths([circle], 0.02)[0] assert len(ring) > 20 assert all(abs(math.hypot(x, y) - 10) < 0.03 for x, y in ring) # -------------------------------------------------------------------------- # Bout en bout (necessite inkex) # -------------------------------------------------------------------------- def run_extension(tmp_path, *args): pytest.importorskip("inkex") from hachures import HatchFill out = tmp_path / "out.svg" HatchFill().run([*args, "--output={}".format(out), SHAPES]) return out.read_text(encoding="utf-8") def laser_segments(svg): """Segments (x0, y0, x1, y1) du chemin « Laser path » du SVG produit.""" node = next(n for n in ET.fromstring(svg).iter() if n.get("{http://www.inkscape.org/namespaces/inkscape}label") == "Laser path") numbers = [float(n) for n in re.findall(r"-?\d+(?:\.\d+)?", node.get("d"))] return [tuple(numbers[i:i + 4]) for i in range(0, len(numbers), 4)] def test_end_to_end_default(tmp_path): svg = run_extension(tmp_path, "--id=rect1") assert "Hatching" in svg and "Laser path" in svg assert 'id="rect1"' in svg assert laser_segments(svg) 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 hachures import HatchFill out = tmp_path / "out.svg" HatchFill().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.2 * 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 test_end_to_end_density_gives_requested_coverage(tmp_path): """rect1 : 50 x 40 ; 40 % avec un trait de 0,25 mm.""" svg = run_extension(tmp_path, "--id=rect1", "--density=40", "--line_width=0.25", "--angle=0") length = sum(math.hypot(x1 - x0, y1 - y0) for x0, y0, x1, y1 in laser_segments(svg)) assert length * 0.25 / (50 * 40) == pytest.approx(0.40, rel=0.05) def test_end_to_end_path_is_optimized(tmp_path): svg = run_extension(tmp_path, "--id=rect1", "--density=50", "--line_width=0.2", "--angle=90") lines = laser_segments(svg) assert len(lines) > 10 travel = sum(math.hypot(b[0] - a[2], b[1] - a[3]) for a, b in zip(lines, lines[1:])) assert travel < len(lines) * 0.45 # un pas (0,4) entre deux traits @pytest.mark.parametrize("target", ["path2", "group1", "circle1", "path1"]) def test_end_to_end_other_shapes(tmp_path, target): svg = run_extension(tmp_path, "--id=" + target, "--density=20") assert laser_segments(svg) def test_end_to_end_cross_and_origin(tmp_path): svg = run_extension(tmp_path, "--id=rect1", "--cross=true", "--anchor=origin") assert laser_segments(svg) def test_end_to_end_too_many_lines_message(tmp_path, capsys): pytest.importorskip("inkex") from hachures import HatchFill HatchFill().run(["--id=rect1", "--line_width=0.0005", "--density=100", "--output={}".format(tmp_path / "out.svg"), SHAPES]) assert "Too many hatch 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 "Hatch 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, "hachures.inx")).getroot() params = {elem.get("name") for elem in root.iter() if elem.tag.rsplit("}", 1)[-1] == "param"} with open(os.path.join(HERE, "hachures.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, "hachures.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