# coding=utf-8 """Tests de l'extension « Voronoi Fill » (pytest).""" import math import os import re import xml.etree.ElementTree as ET import pytest import voronoi_core from voronoi_core import ( FillError, Region, clean_rings, clip_convex, fill_shape, make_points, net_rings, parse_color, point_in_rings, polylines_to_d, region_area, erode_convex, inradius, ring_area, rings_to_d, round_cell, round_convex, voronoi_cells) HERE = os.path.dirname(os.path.abspath(__file__)) SHAPES = os.path.join(HERE, "tests", "data", "shapes.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 circle(cx, cy, r, n=120): return [(cx + r * math.cos(2 * math.pi * k / n), cy + r * math.sin(2 * math.pi * k / n)) for k in range(n)] U_SHAPE = [(0, 0), (30, 0), (30, 30), (20, 30), (20, 10), (10, 10), (10, 30), (0, 30)] def seg_dist(p, a, b): dx, dy = b[0] - a[0], b[1] - a[1] t = max(0.0, min(1.0, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / (dx * dx + dy * dy))) return math.hypot(p[0] - a[0] - t * dx, p[1] - a[1] - t * dy) def ring_dist(p, rings): return min(seg_dist(p, ring[k], ring[(k + 1) % len(ring)]) for ring in rings for k in range(len(ring))) def test_rings_to_d_closes_subpaths(): assert rings_to_d([square(0, 0, 1, 1)], precision=0) == "M 0,0 L 1,0 L 1,1 L 0,1 Z" assert rings_to_d([[(0, 0), (1, 1)]]) == "" def test_clean_rings_removes_duplicates_and_collinear(): ring = [(0, 0), (5, 0), (5, 0), (10, 0), (10, 10), (0, 10), (0, 0)] assert clean_rings([ring, [(0, 0), (1, 1)]]) == \ [[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]] def test_region_area_even_odd(): assert region_area([square(0, 0, 10, 10), square(2, 2, 4, 4)]) == pytest.approx(96) assert region_area([square(0, 0, 10, 10)[::-1]]) == pytest.approx(100) def test_region_index_matches_brute_force(): rings = [circle(50, 50, 40, 400), circle(50, 50, 20, 200)] region = Region(rings) assert region.indexed for x in range(0, 101, 7): for y in range(0, 101, 7): point = (x + 0.13, y + 0.29) assert region.contains(point) == point_in_rings(point, rings) @pytest.mark.parametrize("rings, convex, inside, pieces, area", [ ([square(0, 0, 10, 10)], square(5, 5, 15, 15), True, 1, 25), ([U_SHAPE], square(-5, 20, 35, 25), True, 2, 100), ([square(0, 0, 40, 40), square(10, 10, 30, 30)], square(5, 5, 35, 35), True, 2, 500), ([square(0, 0, 40, 40), square(10, 10, 30, 30)], square(5, 5, 15, 15), True, 1, 75), # Trou au centre du convexe, sans croisement ([square(0, 0, 40, 40), square(18, 18, 22, 22)], square(10, 10, 30, 30), True, 2, 384), ([square(0, 0, 10, 10)], square(20, 20, 30, 30), True, 0, 0), ([square(0, 0, 10, 10)], square(5, 5, 15, 15), False, 1, 75), ([square(0, 0, 10, 10)], square(2, 2, 4, 4), False, 2, 96), ([U_SHAPE], square(-5, 20, 35, 25), False, 3, 700 - 100), ]) def test_clip_convex(rings, convex, inside, pieces, area): result = clip_convex(rings, convex, inside) assert len(result) == pieces assert region_area(result) == pytest.approx(area) def test_clip_convex_degenerate_contact(): """Aretes confondues avec le bord du convexe : reessai sans erreur.""" result = clip_convex([square(0, 0, 10, 10)], square(10, 0, 20, 10)) assert region_area(result) == pytest.approx(0, abs=1e-6) result = clip_convex([square(0, 0, 10, 10)], square(5, 0, 15, 10)) assert region_area(result) == pytest.approx(50, rel=1e-6) def test_clip_convex_symmetric_contact(): """Sommet d'hexagone pile sur un bord vertical : la reduction seule ne suffit pas.""" hexagon = [(1047.5, 779.39), (1047.5, 759.18), (1065.0, 749.08), (1082.5, 759.18), (1082.5, 779.39), (1065.0, 789.49)] result = clip_convex([square(1065, 740, 1205, 1000)], hexagon) assert region_area(result) == pytest.approx(abs(ring_area(hexagon)) / 2, rel=1e-4) @pytest.mark.parametrize("distribution", voronoi_core.DISTRIBUTIONS) def test_fill_regular_grid_on_aligned_box(distribution): shape = [square(1065, 740, 1205, 1000)] cells = fill_shape(shape, 40, 5, distribution=distribution, irregularity=0, seed=6) net = net_rings(shape, cells) total = sum(region_area(c) for c in cells) assert region_area(net) == pytest.approx(140 * 260 - total) assert total > 0.5 * 140 * 260 def test_voronoi_cells_partition_box(): points = make_points("random", (0, 0, 100, 80), 10, seed=3) cells = voronoi_cells(points, (0, 0, 100, 80)) assert sum(ring_area(c) for c in cells) == pytest.approx(8000) for point, cell in zip(points, cells): assert point_in_rings(point, [cell]) def test_voronoi_cells_separated_by_gap(): points = make_points("poisson", (0, 0, 60, 60), 10, seed=2) cells = voronoi_cells(points, (-20, -20, 80, 80), gap=2.0) for k, cell in enumerate(cells): for m, other in enumerate(cells): if m <= k or not cell or not other: continue gap = min(seg_dist(p, other[i], other[(i + 1) % len(other)]) for p in cell for i in range(len(other))) assert gap >= 2.0 - 1e-6 def test_hexagonal_zero_irregularity_gives_hexagons(): points = make_points("hexagonal", (0, 0, 100, 100), 10, irregularity=0) cells = voronoi_cells(points, (0, 0, 100, 100)) inner = [c for p, c in zip(points, cells) if 20 < p[0] < 80 and 20 < p[1] < 80] assert inner and all(len(clean_rings([c])[0]) == 6 for c in inner) assert all(abs(ring_area(c)) == pytest.approx(100 * math.sqrt(3) / 2) for c in inner) def test_poisson_minimum_distance(): points = make_points("poisson", (0, 0, 80, 80), 10, seed=5) limit = voronoi_core.POISSON_RATIO * 10 for k, p in enumerate(points): for q in points[k + 1:]: assert math.hypot(p[0] - q[0], p[1] - q[1]) >= limit - 1e-9 def test_distributions_have_similar_density(): counts = {d: len(make_points(d, (0, 0, 300, 300), 10, seed=4)) for d in voronoi_core.DISTRIBUTIONS} reference = counts["hexagonal"] for count in counts.values(): assert abs(count - reference) / reference < 0.1, counts def test_seed_is_reproducible(): assert make_points("random", (0, 0, 50, 50), 10, seed=7) == \ make_points("random", (0, 0, 50, 50), 10, seed=7) assert make_points("random", (0, 0, 50, 50), 10, seed=7) != \ make_points("random", (0, 0, 50, 50), 10, seed=8) def test_round_convex_offsets_by_radius(): rounded = round_convex(square(0, 0, 10, 10), 2.0, 0.01) area = 100 + 4 * 10 * 2 + math.pi * 4 assert abs(ring_area(rounded)) == pytest.approx(area, rel=1e-3) assert round_convex(square(0, 0, 1, 1), 0, 0.01) == square(0, 0, 1, 1) def test_erode_convex_and_inradius(): assert abs(ring_area(erode_convex(square(0, 0, 10, 10), 2))) == pytest.approx(36) assert erode_convex(square(0, 0, 10, 10), 5.5) == [] assert inradius(square(0, 0, 10, 4)) == pytest.approx(2, rel=1e-4) def test_round_cell_roundness(): hexagon = [(10 * math.cos(math.pi / 3 * k), 10 * math.sin(math.pi / 3 * k)) for k in range(6)] rho = 5 * math.sqrt(3) full = round_cell(hexagon, 0, 1.0, 0.001) # Presque le disque inscrit, et toujours contenu dans l'hexagone. assert abs(ring_area(full)) == pytest.approx(math.pi * rho * rho, rel=0.02) assert all(point_in_rings(p, [hexagon]) or ring_dist(p, [hexagon]) < 1e-6 for p in full) half = round_cell(hexagon, 0, 0.5, 0.001) assert abs(ring_area(full)) < abs(ring_area(half)) < abs(ring_area(hexagon)) # Le plus grand des deux rayons l'emporte ; un rayon fixe trop grand efface. assert round_cell(hexagon, 0, 0, 0.01) == hexagon assert abs(ring_area(round_cell(hexagon, 0.5 * rho, 0.1, 0.001))) == pytest.approx(abs(ring_area(half)), rel=1e-3) assert round_cell(hexagon, 2 * rho, 0, 0.01) is None def test_fill_roundness_keeps_gap_and_shrinks_cells(): shape = [square(0, 0, 80, 80)] sharp = fill_shape(shape, 10, 1, seed=3) round_ = fill_shape(shape, 10, 1, seed=3, roundness=0.8) assert sum(region_area(c) for c in round_) < 0.95 * sum(region_area(c) for c in sharp) assert len(round_) >= 0.9 * len(sharp) rings = [ring for cell in round_ for ring in cell] for k, ring in enumerate(rings[:40]): for other in rings[k + 1:40]: assert min(ring_dist(p, [other]) for p in ring) >= 1 - 1e-6 def test_fill_without_net_covers_shape(): shape = [square(0, 0, 100, 60)] cells = fill_shape(shape, 10, 0.0, border=False, min_area=0, min_thickness=0) assert sum(region_area(c) for c in cells) == pytest.approx(6000) def test_fill_cells_inside_shape_and_away_from_outline(): shape = [circle(50, 50, 40), circle(50, 50, 12)] width = 1.5 cells = fill_shape(shape, 10, width, border=True) assert len(cells) > 20 for cell in cells: for ring in cell: for p in ring: assert point_in_rings(p, shape) assert ring_dist(p, shape) >= width - 0.02 def test_fill_without_border_reaches_outline(): shape = [square(0, 0, 60, 60)] cells = fill_shape(shape, 10, 1.0, border=False) touching = [p for cell in cells for ring in cell for p in ring if ring_dist(p, shape) < 1e-6] assert touching def test_fill_concave_shape_net_is_shape_minus_cells(): shape = [U_SHAPE] cells = fill_shape(shape, 4, 0.5, distribution="hexagonal", border=True) total = sum(region_area(c) for c in cells) assert 0 < total < region_area(shape) net = net_rings(shape, cells) assert region_area(net) == pytest.approx(region_area(shape) - total) def test_fill_empty_and_errors(): assert fill_shape([], 10, 1) == [] assert fill_shape([[(0, 0), (1, 1)]], 10, 1) == [] with pytest.raises(FillError) as info: fill_shape([square(0, 0, 1000, 1000)], 1, 0.1, max_cells=1000) assert info.value.code == "too_many_cells" and info.value.count > 1000 with pytest.raises(FillError): fill_shape([square(0, 0, 10, 10)], 0, 1) # -------------------------------------------------------------------------- # Bout en bout (necessite inkex) # -------------------------------------------------------------------------- def run_extension(tmp_path, *args): pytest.importorskip("inkex") from voronoi_fill import VoronoiFill out = tmp_path / "out.svg" VoronoiFill().run([*args, "--output={}".format(out), SHAPES]) # Document inchange (erreur signalee) : inkex n'ecrit pas de fichier. return out.read_text(encoding="utf-8") if out.exists() else "" def paths_of(svg): return re.findall(r"]*>", svg) def test_end_to_end_default(tmp_path): svg = run_extension(tmp_path, "--id=rect1") assert "Voronoi fill" in svg assert 'id="rect1"' in svg generated = [p for p in paths_of(svg) if "fill-rule:evenodd" in p] assert len(generated) == 1 # filet seul assert "transform" not in generated[0] def test_end_to_end_cells_and_net(tmp_path): svg = run_extension(tmp_path, "--id=path2", "--result=both", "--cell_color={}".format(0x336699FF)) assert len([p for p in paths_of(svg) if "fill:#336699" in p]) > 10 assert len([p for p in paths_of(svg) if "fill:#000000" in p]) == 1 def test_end_to_end_group_and_transform(tmp_path): svg = run_extension(tmp_path, "--id=group1", "--result=cells") assert svg.count("fill-rule:evenodd") > 5 assert "translate(-100" in svg # transformation du parent neutralisee def test_end_to_end_roundness(tmp_path): sharp = run_extension(tmp_path, "--id=rect1") round_ = run_extension(tmp_path, "--id=rect1", "--roundness=100") assert len(round_) > len(sharp) # arcs : bien plus de points 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_net_too_wide(tmp_path, capsys): svg = run_extension(tmp_path, "--id=rect1", "--net_width=20") assert "Voronoi fill" not in svg assert "net width" 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 "Voronoi 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, "voronoi_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, "voronoi_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, "voronoi_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