# coding=utf-8 """Tests du motif « Y fleche » (pytest).""" import math import os import random import re import pytest from pattern_core import (EdgeIndex, clip_polyline, distance_point_segment, fill_pattern, lattice_centers, motif_polylines, parse_color, polyline_length, polylines_to_d, segment_intersection, unit) HERE = os.path.dirname(os.path.abspath(__file__)) SHAPES = os.path.join(HERE, "tests", "data", "shapes.svg") SQUARE = [[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)]] SQUARE_WITH_HOLE = SQUARE + [[(30.0, 30.0), (70.0, 30.0), (70.0, 70.0), (30.0, 70.0)]] def close(a, b, tol=1e-6): return math.isclose(a, b, abs_tol=tol) def segments(polylines): for polyline in polylines: for k in range(len(polyline) - 1): yield polyline[k], polyline[k + 1] def dist_to_rings(point, rings): return min(distance_point_segment(point[0], point[1], *ring[k], *ring[(k + 1) % len(ring)]) for ring in rings for k in range(len(ring))) # -------------------------------------------------------------------------- # Outils # -------------------------------------------------------------------------- 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("0x00ff00ff") == ("#00ff00", 1.0) assert parse_color("pas une couleur") == ("#b3b3b3", 1.0) def test_segment_intersection(): assert close(segment_intersection((0, 0), (10, 0), (5, -5), (5, 5)), 0.5) assert segment_intersection((0, 0), (10, 0), (0, 1), (10, 1)) is None assert segment_intersection((0, 0), (4, 0), (5, -5), (5, 5)) is None def test_edge_index_contains_with_hole(): index = EdgeIndex(SQUARE_WITH_HOLE) assert index.contains(10, 10) assert index.contains(90, 50) assert not index.contains(50, 50) # dans le trou assert not index.contains(-1, 50) assert not index.contains(50, 101) def test_edge_index_matches_brute_force(): ring = [(50 + 40 * math.cos(a) * (1 + 0.3 * math.sin(5 * a)), 50 + 40 * math.sin(a) * (1 + 0.3 * math.sin(5 * a))) for a in [2 * math.pi * k / 300 for k in range(300)]] index = EdgeIndex([ring]) rng = random.Random(1) for _ in range(2000): x, y = rng.uniform(0, 100), rng.uniform(0, 100) inside = False for k in range(len(ring)): (x1, y1), (x2, y2) = ring[k], ring[(k + 1) % len(ring)] if (y1 > y) != (y2 > y) and x1 + (y - y1) * (x2 - x1) / (y2 - y1) > x: inside = not inside assert index.contains(x, y) == inside # -------------------------------------------------------------------------- # Motif # -------------------------------------------------------------------------- def test_motif_structure(): polylines = motif_polylines(0, 0, 8.3, 3.3) assert len(polylines) == 5 assert len(motif_polylines(0, 0, 8.3, 0)) == 2 chevrons = [pl for pl in polylines if len(pl) == 3 and pl[1] != (0, 0)] stems = [pl for pl in polylines if (0, 0) in pl] assert len(chevrons) == 3 and len(stems) == 2 # Les trois pointes sont a distance « bras » du centre. for chevron in chevrons: tip = chevron[1] assert close(math.hypot(*tip), 8.3) assert close(math.dist(chevron[0], tip), 3.3) assert close(math.dist(chevron[2], tip), 3.3) def test_barbs_parallel_to_other_arms(): angle = 17.0 polylines = motif_polylines(0, 0, 10, 4, angle) arm_dirs = [unit(angle + 120 * k) for k in range(3)] for chevron in (pl for pl in polylines if len(pl) == 3 and (0, 0) not in pl): tip = chevron[1] for end in (chevron[0], chevron[2]): bx, by = (end[0] - tip[0]) / 4, (end[1] - tip[1]) / 4 # Chaque barbe est parallele a l'un des trois bras. assert any(abs(bx * dy - by * dx) < 1e-9 for dx, dy in arm_dirs) def test_lattice_neighbours_at_period(): centers = lattice_centers((0, 0, 50, 50), 10, 30, 0) points = [(x, y) for _, _, x, y in centers] for p in points[:20]: nearest = min(math.dist(p, q) for q in points if q != p) assert close(nearest, 10) def test_lattice_covers_bbox(): centers = lattice_centers((0, 0, 100, 60), 7, 0, 0) rng = random.Random(3) points = [(x, y) for _, _, x, y in centers] for _ in range(300): x, y = rng.uniform(5, 95), rng.uniform(5, 55) # Tout point du plan est a moins de periode / sqrt(3) d'un noeud. assert min(math.dist((x, y), p) for p in points) <= 7 / math.sqrt(3) + 1e-9 def test_serpentine_order(): centers = lattice_centers((0, 0, 100, 100), 10, 0, 0) jumps = [math.dist(a[2:], b[2:]) for a, b in zip(centers, centers[1:])] # Dans une ligne on avance d'une periode ; le changement de ligne reste court. assert max(jumps) < 30 def test_default_pattern_has_no_crossings(): """Les traits de deux motifs differents ne se croisent jamais.""" motifs = fill_pattern(SQUARE, 10, mode="whole", clearance=0.0) assert len(motifs) > 20 segs = [(m, s) for m, polylines in enumerate(motifs) for s in segments(polylines)] for a in range(len(segs)): ma, (p, q) = segs[a] for b in range(a + 1, len(segs)): mb, (r, s) = segs[b] if ma == mb: continue if max(p[0], q[0]) < min(r[0], s[0]) or max(r[0], s[0]) < min(p[0], q[0]): continue assert segment_intersection(p, q, r, s) is None # -------------------------------------------------------------------------- # Decoupe et remplissage # -------------------------------------------------------------------------- def test_clip_segment_crossing_square(): index = EdgeIndex(SQUARE) pieces = clip_polyline([(-50, 50), (150, 50)], index) assert len(pieces) == 1 assert close(polyline_length(pieces[0]), 100) def test_clip_segment_through_hole(): index = EdgeIndex(SQUARE_WITH_HOLE) pieces = clip_polyline([(-10, 50), (110, 50)], index) assert len(pieces) == 2 assert close(sum(polyline_length(p) for p in pieces), 60) def test_clip_keeps_polyline_continuity(): index = EdgeIndex(SQUARE) pieces = clip_polyline([(10, 10), (50, 50), (90, 10)], index) assert pieces == [[(10.0, 10.0), (50.0, 50.0), (90.0, 10.0)]] def test_clip_mode_stays_inside(): motifs = fill_pattern(SQUARE_WITH_HOLE, 8, angle=12, mode="clip") index = EdgeIndex(SQUARE_WITH_HOLE) assert motifs for polylines in motifs: for p, q in segments(polylines): mid = ((p[0] + q[0]) / 2, (p[1] + q[1]) / 2) assert index.contains(*mid) or dist_to_rings(mid, SQUARE_WITH_HOLE) < 1e-6 for point in (p, q): assert -1e-6 <= point[0] <= 100 + 1e-6 and -1e-6 <= point[1] <= 100 + 1e-6 def test_whole_mode_respects_clearance(): clearance = 2.5 motifs = fill_pattern(SQUARE_WITH_HOLE, 8, mode="whole", clearance=clearance) assert motifs for polylines in motifs: assert len(polylines) == 5 # motif complet for polyline in polylines: for point in polyline: assert dist_to_rings(point, SQUARE_WITH_HOLE) >= clearance - 1e-9 assert not (30 < point[0] < 70 and 30 < point[1] < 70) def test_whole_mode_fewer_motifs_than_clip(): clip = fill_pattern(SQUARE, 10, mode="clip") whole = fill_pattern(SQUARE, 10, mode="whole") assert 0 < len(whole) < len(clip) def test_same_origin_gives_continuous_pattern(): """Deux formes voisines ancrees a la meme origine partagent le reseau.""" left = [[(0, 0), (50, 0), (50, 50), (0, 50)]] right = [[(50, 0), (100, 0), (100, 50), (50, 50)]] whole = fill_pattern([[(0, 0), (100, 0), (100, 50), (0, 50)]], 9, mode="clip") parts = fill_pattern(left, 9, mode="clip") + fill_pattern(right, 9, mode="clip") total = sum(polyline_length(p) for m in whole for p in m) split = sum(polyline_length(p) for m in parts for p in m) assert close(total, split, 1e-6) def test_empty_inputs(): assert fill_pattern([], 10) == [] assert fill_pattern(SQUARE, 0) == [] tiny = [[(0, 0), (1, 0), (1, 1), (0, 1)]] assert fill_pattern(tiny, 50, mode="whole") == [] def test_polylines_to_d(): d = polylines_to_d([[(0, 0), (1, 2)], [(3, 4), (5, 6), (7, 8)]], precision=1) assert d == "M 0.0,0.0 L 1.0,2.0 M 3.0,4.0 L 5.0,6.0 L 7.0,8.0" # -------------------------------------------------------------------------- # Bout en bout (necessite inkex) # -------------------------------------------------------------------------- def run_extension(tmp_path, *args): pytest.importorskip("inkex") from y_pattern import YArrowPattern out = tmp_path / "out.svg" YArrowPattern().run([*args, "--output={}".format(out), SHAPES]) return out.read_text(encoding="utf-8") def test_end_to_end_clip(tmp_path): svg = run_extension(tmp_path, "--id=rect1", "--id=path2", "--period=8") assert svg.count("Y arrow pattern") == 2 assert 'id="rect1"' in svg def test_end_to_end_whole_removes_original(tmp_path): svg = run_extension(tmp_path, "--id=circle1", "--mode=whole", "--margin=1", "--keep_original=false") assert 'id="circle1"' not in svg assert "Y arrow pattern" in svg def test_end_to_end_color(tmp_path): svg = run_extension(tmp_path, "--id=path1", "--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 du .py a sa traduction dans chaque catalogue.""" import gettext import i18n msgids = [msgid for msgid, _refs in i18n.extract()] assert "Y Arrow Pattern Fill" in msgids and "Y arrow pattern" 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) fr = gettext.translation(i18n.DOMAIN, i18n.LOCALE_DIR, ["fr"]) assert fr.gettext("Y arrow pattern") == "Motif Y fléché" 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_images_exist(): """Les images de la boite de dialogue sont presentes (chemin relatif au .inx).""" import xml.etree.ElementTree as ET root = ET.parse(os.path.join(HERE, "y_pattern.inx")).getroot() images = [elem for elem in root.iter() if elem.tag.rsplit("}", 1)[-1] == "image"] assert images for elem in images: assert os.path.isfile(os.path.join(HERE, elem.text.strip())), elem.text