inkscape.motif.relief/test_relief_fill.py
2026-10-19 20:51:52 +02:00

321 lines
12 KiB
Python

# 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 <param> 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