# 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 (````) 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 = """\ A A B B A->B flow """ 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 `` the transform adds to reposition the node.""" # Isolate the group m = re.search( rf']*data-name="{node_name}"[^>]*>(.*?)\s*(?:)', html, re.DOTALL, ) if not m: m = re.search( rf']*data-name="{node_name}"[^>]*>(.*?)', 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']*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=""): transform_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']*data-source="A"[^>]*data-target="B"[^>]*>.*?' r']*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']*data-source="A"[^>]*data-target="B"[^>]*>.*?' r']*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']*data-source="A"[^>]*data-target="B"[^>]*>.*?' r']*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( 'B', 'UNKNOWN', ) 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 , 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 'A' 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']*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 `` 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']*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 = """\ A A B B """ # 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 = """\ A A B B """ # 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 ```` must appear BEFORE all ```` inside ```` 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 position and the last # position inside the graph body. edge_positions = [m.start() for m in re.finditer(r']*class="edge"', out)] node_positions = [m.start() for m in re.finditer(r']*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( '', '', ) out = transform_html(html, {"A": (0, 0), "B": (100, 100)}) m = re.search( r']*data-source="A"[^>]*>.*?' r']*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 = """\ A B thick C D thin """ 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']*id="{edge_id}"[^>]*>.*?]*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 ```` 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( '', '' '', ) # 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 = """\ A B A->B -25 """ 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']*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']*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( '', '', ).replace('', _TAPERED_HTML.split('')[1].split('')[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 = ( '' '' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' '' '' ) 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']*>(.*?)', out, re.DOTALL, ) assert reg_m, "regular edge group missing" regular_block = reg_m.group(1) regular_polys = re.findall(r']*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']*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']*>(.*?)', out, re.DOTALL, ) assert tap_m, "tapered edge group missing" tapered_block = tap_m.group(1) tapered_polys = re.findall(r']*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 ' child"