Co-Study4Grid / expert_backend /tests /test_overflow_geo_transform.py
github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
23.9 kB
# Copyright (c) 2025-2026, RTE (https://www.rte-france.com)
# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0.
# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file,
# you can obtain one at http://mozilla.org/MPL/2.0/.
# SPDX-License-Identifier: MPL-2.0
"""Tests for the overflow-graph geo-layout transform.
The transform takes the hierarchical overflow HTML produced by
``expert_op4grid_recommender`` and rewrites its SVG so that each
substation (``<g class="node" data-name="...">``) is placed at the
coordinates given in ``grid_layout.json`` and each edge is redrawn
as a straight line between the new node centres. These tests exercise
the pure function directly — no recommender, no graphviz.
"""
from __future__ import annotations
import re
import pytest
from expert_backend.services.analysis.overflow_geo_transform import (
transform_html,
)
HIERARCHICAL_HTML = """\
<!doctype html>
<html><head></head><body>
<svg width="400pt" height="400pt" viewBox="0 0 400 400" xmlns="http://www.w3.org/2000/svg">
<g class="graph">
<g id="node1" class="node" data-name="A" data-attr-pos="10,20">
<title>A</title>
<ellipse cx="10" cy="-20" rx="5" ry="5" stroke="red"/>
<text x="10" y="-18">A</text>
</g>
<g id="node2" class="node" data-name="B" data-attr-pos="50,60">
<title>B</title>
<ellipse cx="50" cy="-60" rx="5" ry="5" stroke="darkgreen"/>
<text x="50" y="-58">B</text>
</g>
<g id="edge1" class="edge" data-source="A" data-target="B" data-attr-color="coral">
<title>A-&gt;B</title>
<path fill="none" stroke="coral" d="M 10,-20 C 20,-30 40,-50 50,-60"/>
<polygon fill="coral" points="52,-58 48,-62 46,-60"/>
<text x="30" y="-40">flow</text>
</g>
</g>
</svg>
</body></html>
"""
def _parse_ellipse_center(html: str, node_name: str) -> tuple[float, float]:
"""Return (cx, cy) of the ellipse inside the named node group,
accounting for any wrapper `<g transform="translate(dx dy)">` the
transform adds to reposition the node."""
# Isolate the <g ... data-name="node_name"> group
m = re.search(
rf'<g[^>]*data-name="{node_name}"[^>]*>(.*?)</g>\s*(?:<g\s+data-name|</g>)',
html,
re.DOTALL,
)
if not m:
m = re.search(
rf'<g[^>]*data-name="{node_name}"[^>]*>(.*?)</g>',
html,
re.DOTALL,
)
assert m, f"Could not find node {node_name}"
inner = m.group(1)
# Find translate() wrapper if any
tx, ty = 0.0, 0.0
mt = re.search(r'transform="translate\(([-\d.]+)\s+([-\d.]+)\)"', inner)
if mt:
tx = float(mt.group(1))
ty = float(mt.group(2))
me = re.search(r'<ellipse[^>]*cx="([-\d.]+)"[^>]*cy="([-\d.]+)"', inner)
assert me, f"No ellipse in node {node_name}"
return float(me.group(1)) + tx, float(me.group(2)) + ty
def test_rejects_html_without_svg():
with pytest.raises(ValueError, match="<svg>"):
transform_html("<html></html>", {"A": (0, 0)})
def test_rejects_when_no_layout_overlaps():
with pytest.raises(ValueError, match="None of the HTML node names"):
transform_html(HIERARCHICAL_HTML, {"Z": (0, 0)})
def test_moves_matched_node_to_new_position():
"""A node whose data-name is in the layout must end up at a
visually different ellipse centre."""
layout = {"A": (0.0, 0.0), "B": (1000.0, 1000.0)}
out = transform_html(HIERARCHICAL_HTML, layout)
# Ellipse effective centres for A and B must change relative to
# the original (10,-20) and (50,-60).
ax, ay = _parse_ellipse_center(out, "A")
bx, by = _parse_ellipse_center(out, "B")
assert (ax, ay) != (10, -20)
assert (bx, by) != (50, -60)
# B has a larger layout (x, y) than A, so its projected x must be
# greater (geo x-axis preserved) and its effective y must be
# visually "higher" (i.e. smaller SVG y) because the transform
# keeps graphviz's y-up convention.
assert bx > ax
assert by < ay
def test_updates_data_attr_pos_so_retransform_is_idempotent():
"""After a transform, the ``data-attr-pos`` of each moved node is
rewritten to the new position so a second pass with the same
layout becomes a no-op."""
layout = {"A": (0.0, 0.0), "B": (100.0, 100.0)}
once = transform_html(HIERARCHICAL_HTML, layout)
twice = transform_html(once, layout)
# The data-attr-pos must be consistent between passes.
positions_once = re.findall(r'data-name="(\w+)" data-attr-pos="([-\d.]+,[-\d.]+)"', once)
positions_twice = re.findall(r'data-name="(\w+)" data-attr-pos="([-\d.]+,[-\d.]+)"', twice)
assert dict(positions_once) == dict(positions_twice)
def test_edge_redrawn_as_straight_line_between_new_centres():
"""The edge path becomes `M x1,y1 L x2,y2` (plus arrow pull-back)."""
layout = {"A": (0.0, 0.0), "B": (1000.0, 1000.0)}
out = transform_html(HIERARCHICAL_HTML, layout)
# Locate the edge path
m = re.search(
r'<g[^>]*data-source="A"[^>]*data-target="B"[^>]*>.*?'
r'<path[^>]*d="(M[^"]+)"',
out,
re.DOTALL,
)
assert m, "No edge path found"
d = m.group(1)
# The redrawn path must have exactly one M and one L.
assert d.count("M") == 1
assert d.count("L") == 1
assert "C" not in d # no bezier curve anymore
def test_edge_arrowhead_has_three_points():
layout = {"A": (0.0, 0.0), "B": (1000.0, 1000.0)}
out = transform_html(HIERARCHICAL_HTML, layout)
m = re.search(
r'<g[^>]*data-source="A"[^>]*data-target="B"[^>]*>.*?'
r'<polygon[^>]*points="([^"]+)"',
out,
re.DOTALL,
)
assert m, "No arrowhead polygon found"
# "x1,y1 x2,y2 x3,y3"
pts = m.group(1).split()
assert len(pts) == 3
def test_edge_label_placed_at_midpoint():
layout = {"A": (0.0, 0.0), "B": (1000.0, 1000.0)}
out = transform_html(HIERARCHICAL_HTML, layout)
# Grab text x,y inside the edge group
m = re.search(
r'<g[^>]*data-source="A"[^>]*data-target="B"[^>]*>.*?'
r'<text[^>]*x="([-\d.]+)"[^>]*y="([-\d.]+)"',
out,
re.DOTALL,
)
assert m, "No edge label text"
# The label must no longer be at its original (30, -40) position.
tx, ty = float(m.group(1)), float(m.group(2))
assert (tx, ty) != (30, -40)
def test_unknown_node_kept_at_original_position():
"""A node whose data-name is absent from the layout is left where
graphviz put it; only nodes in the layout are repositioned."""
html = HIERARCHICAL_HTML.replace(
'<g id="node2" class="node" data-name="B"',
'<g id="node2" class="node" data-name="UNKNOWN"',
).replace(
'data-source="A" data-target="B"', 'data-source="A" data-target="UNKNOWN"',
).replace(
'<title>B</title>', '<title>UNKNOWN</title>',
)
layout = {"A": (0.0, 0.0)}
out = transform_html(html, layout)
# UNKNOWN's ellipse cx must still be 50 because the transform left
# it alone (no wrapper <g translate>, no rewrite of data-attr-pos).
cx, cy = _parse_ellipse_center(out, "UNKNOWN")
assert (cx, cy) == (50, -60)
def test_preserves_styling_attributes():
"""Colors, stroke widths and tooltips survive the transform."""
layout = {"A": (0.0, 0.0), "B": (100.0, 100.0)}
out = transform_html(HIERARCHICAL_HTML, layout)
assert 'stroke="red"' in out
assert 'stroke="coral"' in out
assert '<title>A</title>' in out
assert 'data-name="A"' in out
assert 'data-source="A"' in out
def test_viewbox_rewritten_to_layout_aspect_ratio():
"""The transform MUST rewrite the SVG viewBox so a wide
geographic layout doesn't get squashed into graphviz's narrow
hierarchical canvas — otherwise edges render with no visible
tail (only the arrowhead peeks out from behind the target node)."""
# Horizontally-wide layout (3:1 aspect).
layout = {"A": (0.0, 0.0), "B": (3000.0, 1000.0)}
out = transform_html(HIERARCHICAL_HTML, layout)
m = re.search(r'<svg[^>]*viewBox="([^"]+)"', out)
assert m, "Missing viewBox"
parts = m.group(1).split()
assert len(parts) == 4
vb_w = float(parts[2])
vb_h = float(parts[3])
# Old viewBox was 400x400 (1:1) — it must have changed.
assert (vb_w, vb_h) != (400.0, 400.0)
# The new viewBox must roughly preserve the layout's 3:1 aspect.
assert vb_w > vb_h
def test_graph_transform_reanchored_to_new_viewbox():
"""The top-level `<g class=graph>` transform keeps the y-flip
landing inside the new viewBox. After the rewrite the translate
pair reads ``(MARGIN, new_h - MARGIN)``."""
layout = {"A": (0.0, 0.0), "B": (100.0, 100.0)}
out = transform_html(HIERARCHICAL_HTML, layout)
# Expect the graph-level transform to contain translate(40 ...)
m = re.search(r'<g[^>]*class="graph"[^>]*transform="([^"]+)"', out)
assert m, "Missing graph transform"
assert "translate(40 " in m.group(1)
def test_text_scales_up_on_large_canvas():
"""Labels were sized by graphviz for the original small viewBox.
On a much larger new canvas the `font-size` must grow so labels
stay readable. Node circles (`rx`) stay at their graphviz-native
size so the ``_TARGET_SPACING_RATIO × _NODE_RADIUS_PX`` edge-
spacing invariant isn't broken — enlarging node circles would
hide edges between close-pair substations."""
html = """\
<!doctype html><html><body>
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<g class="graph">
<g id="node1" class="node" data-name="A" data-attr-pos="10,10">
<title>A</title>
<ellipse cx="10" cy="-10" rx="5" ry="5" stroke-width="2"/>
<text x="10" y="-10" font-size="10">A</text>
</g>
<g id="node2" class="node" data-name="B" data-attr-pos="20,20">
<title>B</title>
<ellipse cx="20" cy="-20" rx="5" ry="5" stroke-width="2"/>
<text x="20" y="-20" font-size="10">B</text>
</g>
</g>
</svg>
</body></html>
"""
# A large geographic spread forces the canvas to hit
# _MAX_VIEWBOX_DIM, delivering a much larger new viewBox.
layout = {"A": (0.0, 0.0), "B": (100000.0, 100000.0)}
out = transform_html(html, layout)
font_sizes = [float(v) for v in re.findall(r'font-size="([-\d.]+)"', out)]
rxs = [float(v) for v in re.findall(r'rx="([-\d.]+)"', out)]
# Text grows
assert font_sizes and all(f > 10.0 for f in font_sizes), font_sizes
# Node circles do NOT grow — they keep their graphviz-native rx
# so close-pair edges stay visible.
assert rxs and all(r == 5.0 for r in rxs), rxs
def test_text_scale_leaves_small_canvas_untouched():
"""When the layout is so small that the canvas stays at its
floor, the text-scale clamp keeps font-size at the graphviz
original (no shrink below 1.0× → text stays at its original
size)."""
html = """\
<!doctype html><html><body>
<svg viewBox="0 0 4000 4000" xmlns="http://www.w3.org/2000/svg">
<g class="graph">
<g id="node1" class="node" data-name="A" data-attr-pos="10,10">
<title>A</title>
<ellipse cx="10" cy="-10" rx="5" ry="5"/>
<text x="10" y="-10" font-size="10">A</text>
</g>
<g id="node2" class="node" data-name="B" data-attr-pos="20,20">
<title>B</title>
<ellipse cx="20" cy="-20" rx="5" ry="5"/>
<text x="20" y="-20" font-size="10">B</text>
</g>
</g>
</svg>
</body></html>
"""
# Microscopic layout → natural canvas much smaller than the
# original 4000×4000, so text_scale clamps at 1.0.
layout = {"A": (0.0, 0.0), "B": (0.01, 0.01)}
out = transform_html(html, layout)
font_sizes = [float(v) for v in re.findall(r'font-size="([-\d.]+)"', out)]
# Original was 10 — clamp must not let it go below.
assert font_sizes and all(f >= 10.0 for f in font_sizes), font_sizes
def test_edges_stacked_before_nodes():
"""SVG z-order follows document order. All ``<g class="edge">``
must appear BEFORE all ``<g class="node">`` inside
``<g class="graph">`` so edges render behind nodes and node
circles (+ their labels) stay visible over an overlapping edge."""
layout = {"A": (0.0, 0.0), "B": (100.0, 100.0)}
out = transform_html(HIERARCHICAL_HTML, layout)
# Find the first <g class="node"> position and the last
# <g class="edge"> position inside the graph body.
edge_positions = [m.start() for m in re.finditer(r'<g [^>]*class="edge"', out)]
node_positions = [m.start() for m in re.finditer(r'<g [^>]*class="node"', out)]
assert edge_positions and node_positions, "Expected edges and nodes"
# Every edge must start BEFORE every node.
assert max(edge_positions) < min(node_positions), (
f"edges must come before nodes (last edge at {max(edge_positions)}, "
f"first node at {min(node_positions)})"
)
def test_arrowhead_stroke_zeroed_to_avoid_chunky_triangles():
"""Graphviz inherits a thick ``stroke-width`` (15 px on heavy
lines) onto the arrowhead polygon. Without zeroing it, our small
arrow triangle inflates into a chunky blob. The transform must
set ``stroke-width="0"`` on every redrawn polygon so the triangle
shape matches the ``points`` geometry exactly."""
html = HIERARCHICAL_HTML.replace(
'<polygon fill="coral" points="52,-58 48,-62 46,-60"/>',
'<polygon fill="coral" stroke="coral" stroke-width="15" points="52,-58 48,-62 46,-60"/>',
)
out = transform_html(html, {"A": (0, 0), "B": (100, 100)})
m = re.search(
r'<g[^>]*data-source="A"[^>]*>.*?'
r'<polygon[^>]*stroke-width="([-\d.]+)"',
out,
re.DOTALL,
)
assert m, "Arrow polygon should carry a stroke-width attribute"
assert float(m.group(1)) == 0.0
def test_arrow_size_scales_with_edge_stroke_width():
"""Heavy edges get proportionally bigger arrowheads; thin ones
get small arrowheads. Compare the arrow polygon spans between a
thick-stroke edge and a thin-stroke one."""
html = """\
<!doctype html><html><body>
<svg viewBox="0 0 400 400" xmlns="http://www.w3.org/2000/svg">
<g class="graph">
<g id="node1" class="node" data-name="A" data-attr-pos="10,10">
<title>A</title>
<ellipse cx="10" cy="-10" rx="5" ry="5"/>
</g>
<g id="node2" class="node" data-name="B" data-attr-pos="20,20">
<title>B</title>
<ellipse cx="20" cy="-20" rx="5" ry="5"/>
</g>
<g id="edge1" class="edge" data-source="A" data-target="B">
<title>thick</title>
<path d="M 10,-10 L 20,-20" stroke-width="15"/>
<polygon points="0,0 1,1 2,0"/>
</g>
<g id="node3" class="node" data-name="C" data-attr-pos="30,30">
<title>C</title>
<ellipse cx="30" cy="-30" rx="5" ry="5"/>
</g>
<g id="node4" class="node" data-name="D" data-attr-pos="40,40">
<title>D</title>
<ellipse cx="40" cy="-40" rx="5" ry="5"/>
</g>
<g id="edge2" class="edge" data-source="C" data-target="D">
<title>thin</title>
<path d="M 30,-30 L 40,-40" stroke-width="1.5"/>
<polygon points="0,0 1,1 2,0"/>
</g>
</g>
</svg>
</body></html>
"""
layout = {"A": (0, 0), "B": (1, 1), "C": (2, 2), "D": (3, 3)}
out = transform_html(html, layout)
def polygon_span(edge_id: str) -> float:
m = re.search(
rf'<g[^>]*id="{edge_id}"[^>]*>.*?<polygon[^>]*points="([^"]+)"',
out,
re.DOTALL,
)
assert m, f"No polygon in {edge_id}"
pts = [float(v) for pair in m.group(1).split() for v in pair.split(",")]
xs = pts[0::2]
ys = pts[1::2]
return (max(xs) - min(xs)) + (max(ys) - min(ys))
thick_span = polygon_span("edge1")
thin_span = polygon_span("edge2")
# Thick (stroke 15) must produce a meaningfully larger arrow
# than thin (stroke 1.5).
assert thick_span > thin_span * 2, (thick_span, thin_span)
def test_drops_graphviz_background_polygon():
"""The stale ``<polygon fill="white" stroke="transparent">`` must
not survive the transform — its points are stuck on the original
viewBox and it would render as a stray white rectangle on the
new canvas."""
html = HIERARCHICAL_HTML.replace(
'<g class="graph">',
'<g class="graph">'
'<polygon fill="white" stroke="transparent" points="-4,4 -4,-400 400,-400 400,4 -4,4"/>',
)
# Sanity check the original HTML does carry the background.
assert 'fill="white" stroke="transparent"' in html
out = transform_html(html, {"A": (0, 0), "B": (100, 100)})
assert 'fill="white" stroke="transparent"' not in out
# ---------------------------------------------------------------------
# Tapered (swapped-flow) edges — must keep both body and arrowhead
# visible after the geo transform.
# ---------------------------------------------------------------------
_TAPERED_HTML = """\
<!doctype html>
<html><head></head><body>
<svg width="400pt" height="400pt" viewBox="0 0 400 400" xmlns="http://www.w3.org/2000/svg">
<g class="graph">
<g id="node1" class="node" data-name="A" data-attr-pos="10,20">
<title>A</title>
<ellipse cx="10" cy="-20" rx="5" ry="5" stroke="red"/>
</g>
<g id="node2" class="node" data-name="B" data-attr-pos="50,60">
<title>B</title>
<ellipse cx="50" cy="-60" rx="5" ry="5" stroke="darkgreen"/>
</g>
<g id="edge1" class="edge" data-source="A" data-target="B"
data-attr-style="tapered" data-attr-color="blue" data-attr-dir="both">
<title>A-&gt;B</title>
<polygon fill="blue" stroke="transparent" stroke-width="13"
points="10,-20 12,-22 14,-24 16,-26 18,-28 20,-30 22,-32 24,-34 26,-36 28,-38 30,-40 32,-42 34,-44 36,-46 38,-48 40,-50 42,-52 44,-54 46,-56 48,-58 50,-60"/>
<polygon fill="blue" stroke="blue" stroke-width="13"
points="52,-58 48,-62 46,-60"/>
<text x="30" y="-40">-25</text>
</g>
</g>
</svg>
</body></html>
"""
def test_tapered_edge_keeps_arrowhead_polygon():
"""Tapered edges (swapped-flow) must still have a recognisable
arrowhead at the target end after the geo redraw. Before the
fix the body polygon and the arrowhead polygon were both
rewritten to a 3-vertex triangle, leaving the edge with no
visible body and a stacked arrowhead."""
out = transform_html(_TAPERED_HTML, {"A": (0, 0), "B": (100, 100)})
polygons = re.findall(
r'<polygon[^>]*points="([^"]+)"', out,
)
assert len(polygons) >= 2, (
"tapered edge must retain BOTH polygons (body + arrowhead)"
)
# The first polygon (body) must be a tapered strip with 4
# vertices; the second (arrowhead) must be the 3-point triangle
# produced by ``_arrowhead_points``.
body_pts = polygons[0].split()
arrow_pts = polygons[1].split()
assert len(body_pts) == 4, (
f"tapered body should have 4 vertices, got {len(body_pts)}: {body_pts}"
)
assert len(arrow_pts) == 3, (
f"arrowhead should have 3 vertices, got {len(arrow_pts)}: {arrow_pts}"
)
def test_tapered_edge_body_does_not_collapse_to_a_triangle():
"""Regression: before the fix, the long ~21-vertex tapered body
was overwritten by ``_arrowhead_points`` and became a 3-vertex
triangle stacked on top of the actual arrowhead — visually the
line was gone. Asserting the body stays at 4 vertices guards
that path."""
out = transform_html(_TAPERED_HTML, {"A": (0, 0), "B": (100, 100)})
# Pull the body polygon (the one with stroke="transparent" or 0).
m = re.search(
r'<polygon[^>]*stroke="transparent"[^>]*points="([^"]+)"',
out,
)
assert m, "tapered body polygon (stroke=transparent) lost"
pts = m.group(1).split()
assert len(pts) == 4
def test_mixed_tapered_and_regular_edges_in_same_svg():
"""A graph with BOTH a regular path-based edge AND a tapered
polygon-only edge must transform each correctly:
- the regular edge keeps a single 3-vertex arrowhead polygon
AND a path with two end-points.
- the tapered edge keeps two polygons: a 4-vertex strip body
and a 3-vertex arrowhead.
Mixed-mode coverage is the only way to spot a regression where
a future refactor accidentally applies the tapered branch to a
regular edge or vice-versa."""
html = HIERARCHICAL_HTML.replace(
'<g id="edge1" class="edge" data-source="A" data-target="B" data-attr-color="coral">',
'<g id="edge1" class="edge" data-source="A" data-target="B" data-attr-color="coral">',
).replace('</svg>', _TAPERED_HTML.split('<g class="graph">')[1].split('</svg>')[0])
# The replacement above shoehorns the tapered edge from the
# _TAPERED_HTML fixture INTO the regular fixture; ensure the
# underlying SVG still contains BOTH edges before transforming.
layout = {"A": (0, 0), "B": (100, 100)}
# We can't simply concat both; build a proper mixed SVG instead.
mixed = (
'<!doctype html><html><body>'
'<svg width="400" height="400" viewBox="0 0 400 400" xmlns="http://www.w3.org/2000/svg">'
' <g class="graph">'
' <g id="node1" class="node" data-name="A" data-attr-pos="10,20">'
' <ellipse cx="10" cy="-20" rx="5" ry="5"/>'
' </g>'
' <g id="node2" class="node" data-name="B" data-attr-pos="50,60">'
' <ellipse cx="50" cy="-60" rx="5" ry="5"/>'
' </g>'
' <g id="edge_regular" class="edge" data-source="A" data-target="B" data-attr-color="coral">'
' <path fill="none" stroke="coral" d="M 10,-20 C 20,-30 40,-50 50,-60"/>'
' <polygon fill="coral" points="52,-58 48,-62 46,-60"/>'
' </g>'
' <g id="edge_tapered" class="edge" data-source="A" data-target="B"'
' data-attr-style="tapered" data-attr-color="blue" data-attr-dir="both">'
' <polygon fill="blue" stroke="transparent" stroke-width="13"'
' points="10,-20 12,-22 14,-24 16,-26 18,-28 20,-30 22,-32 24,-34 26,-36 28,-38 30,-40 32,-42 34,-44 36,-46 38,-48 40,-50 42,-52 44,-54 46,-56 48,-58 50,-60"/>'
' <polygon fill="blue" stroke="blue" stroke-width="13" points="52,-58 48,-62 46,-60"/>'
' </g>'
' </g>'
'</svg>'
'</body></html>'
)
out = transform_html(mixed, layout)
# Regular edge: count its polygons (must still be 1) and verify
# path is rewritten to a straight line.
reg_m = re.search(
r'<g id="edge_regular"[^>]*>(.*?)</g>',
out, re.DOTALL,
)
assert reg_m, "regular edge group missing"
regular_block = reg_m.group(1)
regular_polys = re.findall(r'<polygon[^>]*points="([^"]+)"', regular_block)
assert len(regular_polys) == 1, (
f"regular edge should have exactly 1 arrowhead polygon, got {len(regular_polys)}"
)
assert len(regular_polys[0].split()) == 3, "regular arrowhead must be a 3-vertex triangle"
assert re.search(r'<path[^>]*d="M[^"]+L[^"]+"', regular_block), (
"regular edge path must be rewritten as a single ``M src L tgt`` segment"
)
# Tapered edge: must still have 2 polygons (body 4-vertex,
# arrowhead 3-vertex), no path.
tap_m = re.search(
r'<g id="edge_tapered"[^>]*>(.*?)</g>',
out, re.DOTALL,
)
assert tap_m, "tapered edge group missing"
tapered_block = tap_m.group(1)
tapered_polys = re.findall(r'<polygon[^>]*points="([^"]+)"', tapered_block)
assert len(tapered_polys) == 2, (
f"tapered edge should keep 2 polygons (body + arrowhead), got {len(tapered_polys)}"
)
assert len(tapered_polys[0].split()) == 4, "tapered body must be a 4-vertex strip"
assert len(tapered_polys[1].split()) == 3, "tapered arrowhead must be a 3-vertex triangle"
assert '<path' not in tapered_block, "tapered edge must NOT gain a <path> child"