From 4af7bbddd59f77f5e5da7d1a1554996b556cd21f Mon Sep 17 00:00:00 2001 From: marota Date: Mon, 4 May 2026 21:50:34 +0200 Subject: [PATCH 01/13] feat(overflow): interactive overflow analysis tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Embed the alphaDeesp interactive HTML viewer in the Overflow Analysis tab and augment it with Co-Study4Grid–specific behaviour: semantic layer toggles, action pins synchronised with the Action Overview NAD, drill-down to the SLD on double-click, edge / line re-routing in geo mode, and a per-pin filter widget that mirrors the Action Overview filters. See docs/features/interactive-overflow-analysis.md for the full feature contract. Backend: - main.py replaces the static /results/pdf StaticFiles mount with a dynamic GET that injects the overlay block into HTML responses (PDF passthrough preserved). - services/overflow_overlay.py: new module owning the overlay payload — splices a single `` block to + inject before the iframe's ````. + + Single function (rather than a top-level constant) so we can + re-read ``pinGlyph.js`` on every call — useful during dev when the + shared module is being iterated on. In production the FastAPI + process is restarted between deployments anyway. + """ + pin_glyph_js = _load_pin_glyph_js() + + # Note the doubled ``{{ }}`` braces inside the f-string — they + # encode literal ``{`` / ``}`` so the JS object literals survive + # f-string formatting unchanged. + return f""" + + +""" + + +def inject_overlay(html: str) -> str: + """Splice the Co-Study4Grid overlay block before the closing + ```` of an upstream alphaDeesp overflow-graph HTML. + + Idempotent: re-injection on an already-augmented file replaces the + previous block (matched by its ``id`` attributes) so successive + cache fetches do not accumulate duplicate listeners. + + Raises ``ValueError`` if the input lacks a ```` tag. + """ + closing = "" + if closing not in html: + raise ValueError( + "Overflow HTML has no tag — refusing to inject overlay." + ) + + # Strip any prior injection so a re-fetch of the same file doesn't + # accumulate duplicate event listeners (cheap string match — both + # blocks carry deterministic ids). + for marker in ('" if marker.startswith("" + end = html.find(end_tag, start) + if end == -1: + continue + html = html[:start] + html[end + len(end_tag):] + + block = _build_overlay_block() + return html.replace(closing, block + closing, 1) + + +__all__ = ["inject_overlay"] diff --git a/expert_backend/tests/test_overflow_geo_transform.py b/expert_backend/tests/test_overflow_geo_transform.py index 06d9744d..88cd6e29 100644 --- a/expert_backend/tests/test_overflow_geo_transform.py +++ b/expert_backend/tests/test_overflow_geo_transform.py @@ -419,3 +419,156 @@ def test_drops_graphviz_background_polygon(): 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" diff --git a/expert_backend/tests/test_overflow_html_dim_logic.py b/expert_backend/tests/test_overflow_html_dim_logic.py new file mode 100644 index 00000000..f3aa92c8 --- /dev/null +++ b/expert_backend/tests/test_overflow_html_dim_logic.py @@ -0,0 +1,647 @@ +# 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 + +"""End-to-end tests for the overflow-graph viewer layer-toggle bug +fixes. The tests build a small handcrafted overflow graph that +exercises every category of edge / node the layer toggles classify +(hub, on_constrained_path, in_red_loop, is_overload, is_monitored, +plus colour and style discriminators), render it through the upstream +``build_interactive_html`` viewer, and assert the resulting MODEL JSON ++ injected SVG carry the right layer membership. The HTML output is +also re-injected through the Co-Study4Grid overlay so the dynamic +``/results/pdf/{filename}`` route is covered. + +The dim semantics of the JS template are verified via a small jsdom +simulation: we re-implement the recompute rule (``shouldDim``) in +Python — byte-equivalent to the JS — and assert it against the model +membership map. This avoids spinning up Node just to run a few cases +and keeps the contract easy to read. +""" + +from __future__ import annotations + +import json +import re +from typing import Any, Dict, List, Set + +import networkx as nx +import pytest + +pydot = pytest.importorskip("pydot") + +from alphaDeesp.core.graphsAndPaths import OverFlowGraph # noqa: E402 +from alphaDeesp.core.interactive_html import build_interactive_html # noqa: E402 +from alphaDeesp.tests.graphs_test_helpers import make_ofg_with_graph # noqa: E402 + +from expert_backend.services.overflow_overlay import inject_overlay + + +# --------------------------------------------------------------------- +# Fixture: a graph that touches every layer the viewer surfaces +# --------------------------------------------------------------------- + +def _build_full_layer_graph() -> OverFlowGraph: + """Return an OverFlowGraph stub carrying: + + * one overload edge (black, also tagged is_overload) + * one constrained-path edge (blue, on_constrained_path) + * one red-loop edge (coral, in_red_loop, in a coral component) + * one positive-overflow-only edge (coral, NOT in any red loop because + its endpoint has a non-coral neighbour — actually for simplicity + we use a dedicated dyad) + * one monitored line (compound color + is_monitored) + * one reconnectable (dashed) edge + * one non-reconnectable (dotted) edge + * a hub node (is_hub) which by definition picks up + on_constrained_path + in_red_loop + """ + g = nx.MultiDiGraph() + # Nodes + g.add_node("HUB", shape="oval") + g.add_node("OVL_A", shape="oval") # constrained / overload endpoint + g.add_node("OVL_B", shape="oval") + g.add_node("RL_X", shape="oval") # red-loop interior (will collapse) + g.add_node("RL_Y", shape="oval") + g.add_node("MON_A", shape="oval") # monitored line endpoint + g.add_node("MON_B", shape="oval") + g.add_node("RC_A", shape="oval") # reconnectable edge endpoint + g.add_node("RC_B", shape="oval") + g.add_node("NR_A", shape="oval") # non-reconnectable + g.add_node("NR_B", shape="oval") + + # Overload (black) — also part of constrained path + g.add_edge("OVL_A", "OVL_B", name="L_OVL", color="black", label="100") + # Constrained-path blue edge + g.add_edge("OVL_B", "HUB", name="L_BLUE", color="blue", label="-30") + # Pure red-loop component (RL_X — RL_Y, both coral) + g.add_edge("RL_X", "RL_Y", name="L_CORAL_RL", color="coral", label="5") + g.add_edge("RL_Y", "RL_X", name="L_CORAL_RL2", color="coral", label="5") + # Monitored coral line (will get is_monitored) + g.add_edge("MON_A", "MON_B", name="L_MON", color="coral", label="50") + # Reconnectable (dashed) edge — gray-style + g.add_edge("RC_A", "RC_B", name="L_RECO", color="gray", style="dashed", label="0") + # Non-reconnectable (dotted) edge — gray-style + g.add_edge("NR_A", "NR_B", name="L_NRECO", color="gray", style="dotted", label="0") + + ofg = make_ofg_with_graph(g) + # Tag pipeline (mirrors visualization.py order) + ofg.set_hubs_shape(["HUB"], shape_hub="diamond") + ofg.highlight_significant_line_loading({ + "L_OVL": {"before": 95, "after": 110}, + "L_MON": {"before": 80, "after": 92}, + }) + ofg.tag_constrained_path( + lines_constrained_path=["L_OVL", "L_BLUE"], + nodes_constrained_path=["OVL_A", "OVL_B"], + ) + ofg.collapse_red_loops() + # Source-of-truth red-loop tagging — simulates what the + # recommender's ``get_dispatch_edges_nodes(only_loop_paths=True)`` + # would return for this fixture: only the RL_X-RL_Y dyad + # participates in a cycle path. MON_A/MON_B is intentionally NOT + # tagged (no cycle). + ofg.tag_red_loops( + lines_red_loops=["L_CORAL_RL", "L_CORAL_RL2"], + nodes_red_loops=["RL_X", "RL_Y"], + ) + return ofg + + +def _build_html_and_model() -> tuple[str, Dict[str, Any]]: + ofg = _build_full_layer_graph() + pg = nx.drawing.nx_pydot.to_pydot(ofg.g) + html = build_interactive_html(pg, title="layer-coverage") + m = re.search(r"const MODEL = (\{.*?\});\n\(function", html, re.S) + assert m, "Embedded MODEL JSON not found" + return html, json.loads(m.group(1)) + + +def _layers_by_key(model: Dict[str, Any]) -> Dict[str, Dict[str, Any]]: + return {layer["key"]: layer for layer in model["layers"]} + + +# --------------------------------------------------------------------- +# Layer-membership assertions (source-truth, no symbol reinterpretation) +# --------------------------------------------------------------------- + +class TestLayerMembershipsFromSourceFlags: + def test_hubs_layer_includes_only_hub_node(self): + _, model = _build_html_and_model() + hubs = _layers_by_key(model)["semantic:is_hub"] + assert hubs["nodes"] == ["HUB"] + assert hubs["edges"] == [] + + def test_hub_is_also_in_red_loop_and_constrained_path(self): + _, model = _build_html_and_model() + layers = _layers_by_key(model) + assert "HUB" in set(layers["semantic:in_red_loop"]["nodes"]) + assert "HUB" in set(layers["semantic:on_constrained_path"]["nodes"]) + + def test_constrained_path_excludes_coral_edges(self): + _, model = _build_html_and_model() + cp = _layers_by_key(model)["semantic:on_constrained_path"] + # Match by edge-id → look up edge color via model.edges. + edge_colors = {e["id"]: e["attrs"].get("color", "") for e in model["edges"]} + for eid in cp["edges"]: + color = edge_colors[eid] + base = color.split(":", 1)[0].strip().strip('"').lower() + assert base != "coral", ( + f"edge {eid} (color={color!r}) leaked into constrained-path" + ) + + def test_constrained_path_includes_blue_and_black(self): + _, model = _build_html_and_model() + cp = _layers_by_key(model)["semantic:on_constrained_path"] + edges_by_id = {e["id"]: e for e in model["edges"]} + names = {edges_by_id[eid]["attrs"].get("name") for eid in cp["edges"]} + assert "L_OVL" in names + assert "L_BLUE" in names + + def test_red_loop_layer_matches_explicit_source_of_truth(self): + """in_red_loop tagging is now driven by the explicit list passed + from the recommender's ``get_dispatch_edges_nodes(only_loop_paths + =True)`` — which itself iterates ``red_loops.Path`` (actual + cycle paths). The viewer no longer derives membership from + heuristics over the local graph, so a coral edge can be in or + out of the layer regardless of its endpoints' shape.""" + _, model = _build_html_and_model() + rl = _layers_by_key(model)["semantic:in_red_loop"] + edges_by_id = {e["id"]: e for e in model["edges"]} + red_loop_names = {edges_by_id[eid]["attrs"].get("name") for eid in rl["edges"]} + # Fixture explicitly tagged the RL_X-RL_Y dyad as the loop. + assert "L_CORAL_RL" in red_loop_names + assert "L_CORAL_RL2" in red_loop_names + # The monitored coral line MON_A-MON_B is NOT in the cycle. + assert "L_MON" not in red_loop_names + rl_node_names = set(rl["nodes"]) + # HUB is auto-tagged by `set_hubs_shape` (hubs are by + # definition in red loops). RL_X / RL_Y come from the + # explicit ``tag_red_loops`` call. + assert rl_node_names == {"HUB", "RL_X", "RL_Y"} + + def test_red_loop_excludes_blue_and_black_edges(self): + _, model = _build_html_and_model() + rl = _layers_by_key(model)["semantic:in_red_loop"] + edges_by_id = {e["id"]: e for e in model["edges"]} + for eid in rl["edges"]: + base = edges_by_id[eid]["attrs"].get("color", "").split(":", 1)[0] + base = base.strip().strip('"').lower() + assert base == "coral", ( + f"edge {eid} (color={base!r}) leaked into red-loop layer" + ) + + def test_overload_layer_only_contains_black_edges(self): + _, model = _build_html_and_model() + layer = _layers_by_key(model)["semantic:is_overload"] + edges_by_id = {e["id"]: e for e in model["edges"]} + names = {edges_by_id[eid]["attrs"].get("name") for eid in layer["edges"]} + assert names == {"L_OVL"} + + def test_monitored_layer_includes_overloads_as_subset(self): + _, model = _build_html_and_model() + mon = _layers_by_key(model)["semantic:is_monitored"] + edges_by_id = {e["id"]: e for e in model["edges"]} + names = {edges_by_id[eid]["attrs"].get("name") for eid in mon["edges"]} + # Every entry in dict_significant_change is a low-margin / + # monitored line. The overload subset is also tagged as + # overload — they are NOT mutually exclusive layers. + assert names == {"L_MON", "L_OVL"} + + def test_reconnectable_layer_only_contains_dashed_edges(self): + _, model = _build_html_and_model() + layer = _layers_by_key(model).get("style:dashed") + assert layer is not None + edges_by_id = {e["id"]: e for e in model["edges"]} + for eid in layer["edges"]: + assert edges_by_id[eid]["attrs"].get("style", "").lower() == "dashed" + names = {edges_by_id[eid]["attrs"].get("name") for eid in layer["edges"]} + assert names == {"L_RECO"} + + def test_non_reconnectable_layer_only_contains_dotted_edges(self): + _, model = _build_html_and_model() + layer = _layers_by_key(model).get("style:dotted") + assert layer is not None + edges_by_id = {e["id"]: e for e in model["edges"]} + for eid in layer["edges"]: + assert edges_by_id[eid]["attrs"].get("style", "").lower() == "dotted" + names = {edges_by_id[eid]["attrs"].get("name") for eid in layer["edges"]} + assert names == {"L_NRECO"} + + +# --------------------------------------------------------------------- +# Dim semantics (Python twin of the JS `shouldDim`) +# --------------------------------------------------------------------- + +def _should_dim(memberships: List[int], checked_set: Set[int], total: int) -> bool: + """Byte-equivalent of the JS rule in interactive_html.py: + + * `allChecked` (every layer is on) → never dim. + * Element with no memberships → dim whenever `allChecked` is False. + * Else: dim iff none of its memberships is in `checked_set`. + """ + all_checked = len(checked_set) == total + if all_checked: + return False + if not memberships: + return True + return not any(idx in checked_set for idx in memberships) + + +def _node_memberships(model: Dict[str, Any]) -> Dict[str, List[int]]: + out: Dict[str, List[int]] = {} + for i, layer in enumerate(model["layers"]): + for n in layer.get("nodes", []) or []: + out.setdefault(n, []).append(i) + return out + + +def _edge_memberships(model: Dict[str, Any]) -> Dict[str, List[int]]: + out: Dict[str, List[int]] = {} + for i, layer in enumerate(model["layers"]): + for e in layer.get("edges", []) or []: + out.setdefault(e, []).append(i) + return out + + +class TestDimSemantics: + """Confirms the bug fixes the user flagged on 2026-05-04 — the + must-have invariants of the layer-toggle UX.""" + + def test_unselect_all_dims_every_node(self): + _, model = _build_html_and_model() + node_mem = _node_memberships(model) + # Empty checked set = "Unselect all" + for name in {n["name"] for n in model["nodes"]}: + assert _should_dim( + node_mem.get(name, []), set(), len(model["layers"]) + ), f"node {name} stayed visible after unselect-all" + + def test_unselect_all_dims_every_edge(self): + _, model = _build_html_and_model() + edge_mem = _edge_memberships(model) + for e in model["edges"]: + assert _should_dim( + edge_mem.get(e["id"], []), set(), len(model["layers"]) + ), f"edge {e['id']} stayed visible after unselect-all" + + def test_select_all_keeps_every_element_visible(self): + _, model = _build_html_and_model() + node_mem = _node_memberships(model) + edge_mem = _edge_memberships(model) + all_idx = set(range(len(model["layers"]))) + for name in {n["name"] for n in model["nodes"]}: + assert not _should_dim( + node_mem.get(name, []), all_idx, len(model["layers"]) + ) + for e in model["edges"]: + assert not _should_dim( + edge_mem.get(e["id"], []), all_idx, len(model["layers"]) + ) + + def test_constrained_path_only_visible_with_only_that_layer(self): + _, model = _build_html_and_model() + layer_keys = [layer["key"] for layer in model["layers"]] + cp_idx = layer_keys.index("semantic:on_constrained_path") + checked = {cp_idx} + + node_mem = _node_memberships(model) + edge_mem = _edge_memberships(model) + cp_layer = _layers_by_key(model)["semantic:on_constrained_path"] + cp_node_set = set(cp_layer["nodes"]) + cp_edge_set = set(cp_layer["edges"]) + + # Every node IN the constrained-path layer is visible. + for n in cp_node_set: + assert not _should_dim( + node_mem.get(n, []), checked, len(model["layers"]) + ), f"constrained-path node {n} was wrongly dimmed" + # Every node NOT in any layer claimed by the checked set is + # dimmed — including all nodes whose only memberships were + # color/style/other semantic layers. + for n in {n["name"] for n in model["nodes"]} - cp_node_set: + assert _should_dim( + node_mem.get(n, []), checked, len(model["layers"]) + ), f"non-constrained-path node {n} stayed visible" + # Edge mirror. + for eid in cp_edge_set: + assert not _should_dim( + edge_mem.get(eid, []), checked, len(model["layers"]) + ) + for e in model["edges"]: + if e["id"] in cp_edge_set: + continue + assert _should_dim( + edge_mem.get(e["id"], []), checked, len(model["layers"]) + ), f"non-constrained edge {e['id']} stayed visible" + + def test_red_loop_only_visible_with_only_that_layer(self): + _, model = _build_html_and_model() + layer_keys = [layer["key"] for layer in model["layers"]] + rl_idx = layer_keys.index("semantic:in_red_loop") + checked = {rl_idx} + + edge_mem = _edge_memberships(model) + rl_layer = _layers_by_key(model)["semantic:in_red_loop"] + rl_edge_set = set(rl_layer["edges"]) + + # Hub belongs to the red-loop layer (definition-level). + node_mem = _node_memberships(model) + assert not _should_dim( + node_mem.get("HUB", []), checked, len(model["layers"]) + ) + + # No black/blue edge survives in red-loop-only view. + edges_by_id = {e["id"]: e for e in model["edges"]} + for e in model["edges"]: + if e["id"] in rl_edge_set: + continue + assert _should_dim( + edge_mem.get(e["id"], []), checked, len(model["layers"]) + ), ( + f"non-red-loop edge {e['id']} " + f"(color={edges_by_id[e['id']]['attrs'].get('color')!r}) " + f"stayed visible" + ) + + def test_reconnectable_only_visible_with_only_that_layer(self): + _, model = _build_html_and_model() + layer_keys = [layer["key"] for layer in model["layers"]] + rec_idx = layer_keys.index("style:dashed") + checked = {rec_idx} + + edge_mem = _edge_memberships(model) + rec_layer = _layers_by_key(model)["style:dashed"] + rec_edge_set = set(rec_layer["edges"]) + + # Dashed edges visible. + for eid in rec_edge_set: + assert not _should_dim( + edge_mem.get(eid, []), checked, len(model["layers"]) + ) + # Coloured non-dashed edges (e.g. blue, coral) must NOT be + # visible — that was the explicit bug the user reported. + for e in model["edges"]: + if e["id"] in rec_edge_set: + continue + assert _should_dim( + edge_mem.get(e["id"], []), checked, len(model["layers"]) + ), f"non-dashed edge {e['id']} stayed visible" + + def test_non_reconnectable_only_visible_with_only_that_layer(self): + _, model = _build_html_and_model() + layer_keys = [layer["key"] for layer in model["layers"]] + nr_idx = layer_keys.index("style:dotted") + checked = {nr_idx} + + edge_mem = _edge_memberships(model) + nr_layer = _layers_by_key(model)["style:dotted"] + nr_edge_set = set(nr_layer["edges"]) + + for eid in nr_edge_set: + assert not _should_dim( + edge_mem.get(eid, []), checked, len(model["layers"]) + ) + # Coloured non-dotted edges must NOT survive. + for e in model["edges"]: + if e["id"] in nr_edge_set: + continue + assert _should_dim( + edge_mem.get(e["id"], []), checked, len(model["layers"]) + ) + + +# --------------------------------------------------------------------- +# Co-Study4Grid overlay carries the dblclick→SLD wiring +# --------------------------------------------------------------------- + +class TestOverlayDoubleClickWiring: + def test_overflow_html_includes_dblclick_postmessage(self): + html, _ = _build_html_and_model() + # Upstream JS forwards dblclick to the parent window. + assert "cs4g:overflow-node-double-clicked" in html + + def test_inject_overlay_does_not_strip_dblclick_wiring(self): + html, _ = _build_html_and_model() + injected = inject_overlay(html) + assert "cs4g:overflow-node-double-clicked" in injected + # Overlay-side script also present. + assert "cs4g-overlay-script" in injected + + +# --------------------------------------------------------------------- +# End-to-end against the user's small-grid config (P.SAOL31RONCI) +# --------------------------------------------------------------------- + +class TestSmallGridOverflowGraphLayers: + """Regression test against the actual ``Overflow_Graph_P.SAOL31RONCI*.html`` + produced by the recommender on the bare_env_small_grid_test fixture. + + Skipped if the HTML hasn't been generated yet (e.g. a fresh + checkout running tests before any analysis run). The asserts + capture the user-reported bug class — extras nodes leaking into + the constrained-path layer and missing hub auto-flags.""" + + HTML_PATH = ( + "/home/marotant/dev/AntiGravity/ExpertAssist/Overflow_Graph/" + "Overflow_Graph_P.SAOL31RONCI_chronic_grid.xiidm_" + "timestep_9_hierarchi_only_signif_edges_no_consoli.html" + ) + + def _load_model(self): + import os + if not os.path.isfile(self.HTML_PATH): + pytest.skip(f"Generated HTML not present: {self.HTML_PATH}") + with open(self.HTML_PATH, "r", encoding="utf-8") as fh: + html = fh.read() + import re + m = re.search(r"const MODEL = (\{.*?\});\n\(function", html, re.S) + return json.loads(m.group(1)) + + def _layers(self, model): + return {layer["key"]: layer for layer in model["layers"]} + + def test_constrained_path_does_not_include_side_branch_nodes(self): + """Side-branch nodes (e.g. MAGNYP3, MAGNYP6, ZCRIMP3) live in + ``other_blue_nodes`` upstream — they must NOT appear on the + strict constrained path.""" + model = self._load_model() + cp = self._layers(model).get("semantic:on_constrained_path") + assert cp is not None + cp_nodes = set(cp["nodes"]) + forbidden = {"MAGNYP3", "MAGNYP6", "ZCRIMP3"} + leak = cp_nodes & forbidden + assert not leak, f"Side-branch nodes leaked into constrained path: {leak}" + + def test_constrained_path_excludes_coral_edges(self): + model = self._load_model() + cp = self._layers(model).get("semantic:on_constrained_path") + edges_by_id = {e["id"]: e for e in model["edges"]} + for eid in cp["edges"]: + color = edges_by_id[eid]["attrs"].get("color", "") + base = ( + color.split(":", 1)[0].strip().strip('"').lower() + if isinstance(color, str) + else "" + ) + assert base != "coral", ( + f"coral edge {eid} (color={color!r}) on constrained path" + ) + + def test_every_hub_is_in_red_loop_and_on_constrained_path(self): + """Hubs are by definition in both layers — verify on real data.""" + model = self._load_model() + layers = self._layers(model) + hubs = layers.get("semantic:is_hub") + rl = layers.get("semantic:in_red_loop") + cp = layers.get("semantic:on_constrained_path") + assert hubs and rl and cp + rl_set, cp_set = set(rl["nodes"]), set(cp["nodes"]) + for h in hubs["nodes"]: + assert h in rl_set, f"hub {h} missing from red-loop layer" + assert h in cp_set, f"hub {h} missing from constrained-path layer" + + def test_overload_layer_has_exactly_the_overload(self): + """Only the BEON-CPVAN overloaded line (1 edge) should be + flagged ``is_overload`` for this scenario.""" + model = self._load_model() + ovl = self._layers(model).get("semantic:is_overload") + assert ovl is not None + assert len(ovl["edges"]) == 1, ( + f"expected exactly 1 overload edge, got {len(ovl['edges'])}" + ) + + def test_every_red_loop_edge_has_endpoints_among_red_loop_nodes(self): + """Source-of-truth invariant: every in_red_loop edge connects + two nodes that are themselves in_red_loop. Both come from the + recommender's ``get_dispatch_edges_nodes(only_loop_paths=True)`` + — the line filter keeps only edges whose endpoints are in the + node list, so this invariant is symmetric by construction.""" + model = self._load_model() + rl = self._layers(model).get("semantic:in_red_loop") + edges_by_id = {e["id"]: e for e in model["edges"]} + rl_node_set = set(rl["nodes"]) + for eid in rl["edges"]: + e = edges_by_id[eid] + assert e["source"] in rl_node_set, ( + f"red-loop edge {eid} source {e['source']!r} not in red-loop nodes" + ) + assert e["target"] in rl_node_set, ( + f"red-loop edge {eid} target {e['target']!r} not in red-loop nodes" + ) + + def test_user_listed_edges_ARE_on_constrained_path(self): + """Direct twin of the user's complaint: the four edges they + called out as missing must be on the constrained-path layer.""" + model = self._load_model() + cp = self._layers(model).get("semantic:on_constrained_path") + edges_by_id = {e["id"]: e for e in model["edges"]} + cp_set = set(cp["edges"]) + # (source, target, expected line names — BLUE only; the dimgray + # CPVANY632 is NOT on CP because it's null-flow) + wanted = { + ("SSV.OP7", "GROSNP7"): {"GROSNL71SSV.O"}, + ("CHALOP6", "CPVANP6"): {"CHALOL61CPVAN"}, + ("CPVANP6", "CPVANP3"): {"CPVANY631", "CPVANY633"}, + ("VIELMP7", "VIELMP6"): {"VIELMY762", "VIELMY763"}, + } + for (s, t), expected_names in wanted.items(): + on_cp = set() + for e in model["edges"]: + if (e["source"] == s and e["target"] == t) or ( + e["source"] == t and e["target"] == s + ): + if e["id"] in cp_set: + on_cp.add(e["attrs"].get("name")) + assert expected_names <= on_cp, ( + f"{s}↔{t}: expected {expected_names} on CP, got {on_cp}" + ) + + def test_svg_data_attrs_consistent_with_titles(self): + """Regression for the user-reported edge-id misalignment: + graphviz emits SVG and JSON edge IDs in independent orders, so + before the alignment pass the SVG element ``edgeN`` could carry + ``data-source`` / ``data-target`` referring to a different edge + than its own ```` says. After the fix, every SVG edge's + title and data-* attributes must agree.""" + import html as _html_mod + with open(self.HTML_PATH) as f: + html = f.read() + svg_block = re.search(r"<svg[^>]*>.*?</svg>", html, re.S).group(0) + edge_blocks = re.findall( + r'<g id="(edge\d+)" class="edge"[^>]*' + r'data-source="([^"]*)"[^>]*data-target="([^"]*)"[^>]*>' + r'\s*<title>([^<]*)', + svg_block, + ) + assert edge_blocks, "no edge blocks parsed" + mismatches = [] + for gid, src, tgt, title in edge_blocks: + t = _html_mod.unescape(title) + for sep in ("->", "--"): + if sep in t: + a, b = t.split(sep, 1) + if (a.strip(), b.strip()) != (src, tgt): + mismatches.append( + (gid, (a.strip(), b.strip()), (src, tgt)) + ) + break + assert not mismatches, ( + f"{len(mismatches)} edges have title ≠ data-source/data-target: " + + "; ".join( + f"{gid}: title{tt} ≠ data{dd}" + for gid, tt, dd in mismatches[:5] + ) + ) + + def test_constrained_path_only_blue_or_black_edges(self): + """Direct twin of the user's complaint: NO non-blue/black edges + from VIELMP7, SSV.OP7, CPVANP6, CHALOP6 (or anywhere else) + should be on the constrained-path layer.""" + model = self._load_model() + cp = self._layers(model).get("semantic:on_constrained_path") + edges_by_id = {e["id"]: e for e in model["edges"]} + leaks = [] + for eid in cp["edges"]: + e = edges_by_id[eid] + color = e["attrs"].get("color", "") + base = ( + color.split(":", 1)[0].strip().strip('"').lower() + if isinstance(color, str) + else "" + ) + if base not in ("blue", "black"): + leaks.append( + f"{e['attrs'].get('name')} ({e['source']}→{e['target']}," + f" color={color!r})" + ) + assert not leaks, ( + "Non-blue/black edges leaked into constrained path: " + + ", ".join(leaks) + ) + + def test_red_loop_is_consistent_with_recommender_cycle_paths(self): + """For the small-grid scenario, the recommender's + ``red_loops.Path`` includes the cycle ``[CHALOP6, CHALOP3, + LOUHAP3]``. Therefore the CHALOY63x transformers AND the + dashed CHALOL31LOUHA edge are part of a red loop. + + This documents the source-of-truth contract: the viewer + propagates whatever the recommender's structured analysis + returned. Any disagreement with the operator's mental model + should be raised against the recommender's ``find_loops`` + algorithm — not the viewer.""" + model = self._load_model() + rl = self._layers(model).get("semantic:in_red_loop") + edges_by_id = {e["id"]: e for e in model["edges"]} + rl_names = {edges_by_id[eid]["attrs"].get("name") for eid in rl["edges"]} + # The cycle CHALOP6→CHALOP3→LOUHAP3→...→CHALOP6 is in the + # recommender's red_loops.Path — so the parallel transformers + # belong to it. (See the dump in test data setup.) + assert "CHALOY631" in rl_names + assert "CHALOY632" in rl_names + assert "CHALOY633" in rl_names + rl_node_set = set(rl["nodes"]) + assert {"CHALOP6", "CHALOP3", "LOUHAP3"} <= rl_node_set diff --git a/expert_backend/tests/test_overflow_overlay.py b/expert_backend/tests/test_overflow_overlay.py new file mode 100644 index 00000000..10c227d9 --- /dev/null +++ b/expert_backend/tests/test_overflow_overlay.py @@ -0,0 +1,412 @@ +# 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 + +"""Unit tests for the Co-Study4Grid overflow-graph overlay injector and +the dynamic ``/results/pdf/{filename}`` route that serves it. + +The overlay is plain string substitution by design (no HTML parser +dependency) — exercising it is therefore deterministic and fast. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from expert_backend.main import app +from expert_backend.services.overflow_overlay import inject_overlay + + +_BASE_HTML = ( + 'x' + '
' +) + + +# --------------------------------------------------------------------- +# inject_overlay — pure-string injector +# --------------------------------------------------------------------- + +class TestInjectOverlay: + def test_injects_style_and_script_before_closing_body(self) -> None: + out = inject_overlay(_BASE_HTML) + # The original body content must still be there. + assert '
' in out + # Both blocks must appear AND be positioned before . + style_at = out.find(' - - -
- - diff --git a/frontend/src/App.contingency.test.tsx b/frontend/src/App.contingency.test.tsx index 425197b8..26ae55d4 100644 --- a/frontend/src/App.contingency.test.tsx +++ b/frontend/src/App.contingency.test.tsx @@ -113,6 +113,7 @@ const mockApi = vi.hoisted(() => ({ getBranches: vi.fn().mockResolvedValue({ branches: ['BRANCH_A', 'BRANCH_B', 'BRANCH_C'], name_map: {} }), getVoltageLevels: vi.fn().mockResolvedValue({ voltage_levels: ['VL1', 'VL2'], name_map: {} }), getNominalVoltages: vi.fn().mockResolvedValue({ mapping: {}, unique_kv: [63, 225] }), + getVoltageLevelSubstations: vi.fn().mockResolvedValue({ mapping: {} }), getNetworkDiagram: vi.fn().mockResolvedValue({ svg: '', metadata: null }), getN1Diagram: vi.fn().mockResolvedValue({ svg: '', metadata: null, lines_overloaded: [] }), pickPath: vi.fn(), @@ -307,6 +308,7 @@ describe('Overload Clearing Logic', () => { mockApi.getBranches.mockResolvedValue({ branches: ['BRANCH_A', 'BRANCH_B', 'BRANCH_C'], name_map: {} }); mockApi.getVoltageLevels.mockResolvedValue({ voltage_levels: ['VL1', 'VL2'], name_map: {} }); mockApi.getNominalVoltages.mockResolvedValue({ mapping: {}, unique_kv: [63, 225] }); + mockApi.getVoltageLevelSubstations.mockResolvedValue({ mapping: {} }); mockApi.getNetworkDiagram.mockResolvedValue({ svg: '', metadata: null }); mockApi.getN1Diagram.mockResolvedValue({ svg: '', metadata: null, lines_overloaded: [] }); mockApi.runAnalysisStep1.mockResolvedValue({ can_proceed: true, lines_overloaded: ['LINE_OL1'] }); @@ -495,6 +497,7 @@ describe('N-1 overload state is populated before action analysis', () => { mockApi.getBranches.mockResolvedValue({ branches: ['BRANCH_A', 'BRANCH_B', 'BRANCH_C'], name_map: {} }); mockApi.getVoltageLevels.mockResolvedValue({ voltage_levels: ['VL1', 'VL2'], name_map: {} }); mockApi.getNominalVoltages.mockResolvedValue({ mapping: {}, unique_kv: [63, 225] }); + mockApi.getVoltageLevelSubstations.mockResolvedValue({ mapping: {} }); mockApi.getNetworkDiagram.mockResolvedValue({ svg: '', metadata: null }); mockApi.getN1Diagram.mockResolvedValue({ svg: '', metadata: null, lines_overloaded: [] }); localStorage.clear(); diff --git a/frontend/src/App.datalist.test.tsx b/frontend/src/App.datalist.test.tsx index 53c499bd..7fa8c4bc 100644 --- a/frontend/src/App.datalist.test.tsx +++ b/frontend/src/App.datalist.test.tsx @@ -18,6 +18,7 @@ const mockApi = vi.hoisted(() => ({ getBranches: vi.fn().mockResolvedValue({ branches: [], name_map: {} }), getVoltageLevels: vi.fn().mockResolvedValue({ voltage_levels: ['VL1'], name_map: {} }), getNominalVoltages: vi.fn().mockResolvedValue({ mapping: {}, unique_kv: [63, 225] }), + getVoltageLevelSubstations: vi.fn().mockResolvedValue({ mapping: {} }), getNetworkDiagram: vi.fn().mockResolvedValue({ svg: '', metadata: null }), getUserConfig: vi.fn().mockResolvedValue({ network_path: '/path', action_file_path: '/path' }), getConfigFilePath: vi.fn().mockResolvedValue('/config'), diff --git a/frontend/src/App.session.test.tsx b/frontend/src/App.session.test.tsx index c6f5cfe3..48541588 100644 --- a/frontend/src/App.session.test.tsx +++ b/frontend/src/App.session.test.tsx @@ -103,6 +103,7 @@ const mockApi = vi.hoisted(() => ({ getBranches: vi.fn().mockResolvedValue({ branches: ['BRANCH_A', 'BRANCH_B', 'BRANCH_C'], name_map: {} }), getVoltageLevels: vi.fn().mockResolvedValue({ voltage_levels: ['VL1', 'VL2'], name_map: {} }), getNominalVoltages: vi.fn().mockResolvedValue({ mapping: {}, unique_kv: [63, 225] }), + getVoltageLevelSubstations: vi.fn().mockResolvedValue({ mapping: {} }), getNetworkDiagram: vi.fn().mockResolvedValue({ svg: '', metadata: null }), getN1Diagram: vi.fn().mockResolvedValue({ svg: '', metadata: null, lines_overloaded: [] }), pickPath: vi.fn(), diff --git a/frontend/src/App.settings.test.tsx b/frontend/src/App.settings.test.tsx index 01e822f4..7eb5c136 100644 --- a/frontend/src/App.settings.test.tsx +++ b/frontend/src/App.settings.test.tsx @@ -123,6 +123,7 @@ const mockApi = vi.hoisted(() => ({ getBranches: vi.fn().mockResolvedValue({ branches: ['BRANCH_A', 'BRANCH_B', 'BRANCH_C'], name_map: {} }), getVoltageLevels: vi.fn().mockResolvedValue({ voltage_levels: ['VL1', 'VL2'], name_map: {} }), getNominalVoltages: vi.fn().mockResolvedValue({ mapping: {}, unique_kv: [63, 225] }), + getVoltageLevelSubstations: vi.fn().mockResolvedValue({ mapping: {} }), getNetworkDiagram: vi.fn().mockResolvedValue({ svg: '', metadata: null }), getN1Diagram: vi.fn().mockResolvedValue({ svg: '', metadata: null, lines_overloaded: [] }), pickPath: vi.fn(), diff --git a/frontend/src/App.stateManagement.test.tsx b/frontend/src/App.stateManagement.test.tsx index ea468b98..14401e05 100644 --- a/frontend/src/App.stateManagement.test.tsx +++ b/frontend/src/App.stateManagement.test.tsx @@ -117,6 +117,7 @@ const mockApi = vi.hoisted(() => ({ getBranches: vi.fn().mockResolvedValue({ branches: ['BRANCH_A', 'BRANCH_B', 'BRANCH_C'], name_map: {} }), getVoltageLevels: vi.fn().mockResolvedValue({ voltage_levels: ['VL1', 'VL2'], name_map: {} }), getNominalVoltages: vi.fn().mockResolvedValue({ mapping: {}, unique_kv: [63, 225] }), + getVoltageLevelSubstations: vi.fn().mockResolvedValue({ mapping: {} }), getNetworkDiagram: vi.fn().mockResolvedValue({ svg: '', metadata: null }), getN1Diagram: vi.fn().mockResolvedValue({ svg: '', metadata: null, lines_overloaded: [] }), pickPath: vi.fn(), diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 91c9d767..bb9d07da 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -32,6 +32,10 @@ import { useDiagramHighlights } from './hooks/useDiagramHighlights'; import { interactionLogger } from './utils/interactionLogger'; import { DEFAULT_ACTION_OVERVIEW_FILTERS } from './utils/actionTypes'; import { applyVlTitles } from './utils/svgUtils'; +import { + buildOverflowPinPayload, + buildOverflowUnsimulatedPinPayload, +} from './utils/svg/overflowPinPayload'; function App() { // ===== Settings Hook ===== @@ -68,6 +72,14 @@ function App() { const [voltageLevels, setVoltageLevels] = useState([]); /** ID → human-readable name for branches (lines + transformers) and VLs. */ const [nameMap, setNameMap] = useState>({}); + /** VL id → substation id. Loaded once after config-load and used to + * anchor the action-overview pins on the overflow graph (whose + * nodes are pypowsybl substation ids). */ + const [vlToSubstation, setVlToSubstation] = useState>({}); + /** Whether the user has switched ON the pin overlay on the overflow + * graph. Default OFF; toggle is disabled until Step 2 has produced + * a non-empty `result.actions` map. */ + const [overflowPinsEnabled, setOverflowPinsEnabled] = useState(false); const [configLoading, setConfigLoading] = useState(false); const [error, setError] = useState(''); @@ -383,8 +395,76 @@ function App() { diagrams.lastZoomState.current = { query: '', branch: '' }; setSelectedBranch(''); setShowMonitoringWarning(false); + setVlToSubstation({}); + setOverflowPinsEnabled(false); }, [clearContingencyState, diagrams, setShowMonitoringWarning]); + // Pre-compute the pin descriptors posted to the overflow-graph + // iframe. Memoised so unrelated re-renders don't churn the iframe + // postMessage. The toggle is gated on Step 2 having delivered at + // least one action — the iframe overlay is otherwise useless. + const overflowPinsAvailable = useMemo( + () => !analysisLoading && !!result?.actions && Object.keys(result.actions).length > 0, + [analysisLoading, result?.actions], + ); + const overflowPins = useMemo( + () => overflowPinsAvailable + ? buildOverflowPinPayload( + result?.actions ?? null, + diagrams.n1MetaIndex ?? null, + vlToSubstation, + monitoringFactor, + selectedActionIds, + rejectedActionIds, + undefined, + overviewFilters, + ) + : [], + [overflowPinsAvailable, result?.actions, diagrams.n1MetaIndex, vlToSubstation, + monitoringFactor, selectedActionIds, rejectedActionIds, overviewFilters], + ); + + // Un-simulated overflow pins. Built only when the operator has + // ticked ``Show unsimulated`` in the Action-Overview filter row + // (which is mirrored in the iframe sidebar's filter panel). + // Identical contract to the Action Overview pin layer: + // - dimmed grey pin with '?' label, + // - dblclick triggers a manual simulation rather than the SLD + // drill-down, + // - skipped when the id is already in ``result.actions``. + const overflowUnsimulatedPins = useMemo( + () => (overflowPinsAvailable && overviewFilters?.showUnsimulated) + ? buildOverflowUnsimulatedPinPayload( + unsimulatedActionIds, + new Set(Object.keys(result?.actions ?? {})), + diagrams.n1MetaIndex ?? null, + vlToSubstation, + unsimulatedActionInfo, + ) + : [], + [overflowPinsAvailable, overviewFilters?.showUnsimulated, + unsimulatedActionIds, unsimulatedActionInfo, + result?.actions, diagrams.n1MetaIndex, vlToSubstation], + ); + + // Pin payload posted to the iframe is the union of simulated + + // un-simulated pins. The overlay differentiates them via the + // ``unsimulated`` flag on each pin. + const allOverflowPins = useMemo( + () => [...overflowPins, ...overflowUnsimulatedPins], + [overflowPins, overflowUnsimulatedPins], + ); + + // Auto-disable the toggle when the gate goes away (e.g. user + // applied new settings, which clears the result). Without this, + // the toggle would stay ON but the toolbar would show 'OFF' style + // because `overflowPinsAvailable` is false. + useEffect(() => { + if (!overflowPinsAvailable && overflowPinsEnabled) { + setOverflowPinsEnabled(false); + } + }, [overflowPinsAvailable, overflowPinsEnabled]); + const wrappedActionSelect = useCallback( (actionId: string | null) => diagrams.handleActionSelect(actionId, result, selectedBranch, voltageLevels.length, setResult, setError), @@ -746,11 +826,15 @@ function App() { // only started after branches resolved — wasting the ~0.8s branches // gap off the critical path of the initial load. // See docs/performance/history/loading-parallel.md. - const [branchRes, vlRes, nomVRes, diagramRaw] = await Promise.all([ + const [branchRes, vlRes, nomVRes, diagramRaw, vlSubRes] = await Promise.all([ api.getBranches(), api.getVoltageLevels(), api.getNominalVoltages(), api.getNetworkDiagram(), + // Cheap query (~1 ms even on PyPSA-EUR France); pulled in + // parallel so it never extends the critical path. Used to + // anchor overflow-graph action pins on substations. + api.getVoltageLevelSubstations().catch(() => ({ mapping: {} as Record })), ]); setBranches(branchRes.branches); @@ -766,6 +850,7 @@ function App() { } diagrams.ingestBaseDiagram(diagramRaw, vlRes.voltage_levels.length); + setVlToSubstation(vlSubRes.mapping || {}); committedNetworkPathRef.current = networkPath; interactionLogger.record('config_loaded', buildConfigInteractionDetails()); @@ -808,11 +893,15 @@ function App() { // See the sibling call site in `applySettingsImmediate` for context: // fire 4 XHRs in parallel so the slow base-diagram call overlaps // with branches/voltage-levels/nominal-voltages. - const [branchRes, vlRes, nomVRes, diagramRaw] = await Promise.all([ + const [branchRes, vlRes, nomVRes, diagramRaw, vlSubRes] = await Promise.all([ api.getBranches(), api.getVoltageLevels(), api.getNominalVoltages(), api.getNetworkDiagram(), + // Cheap query (~1 ms even on PyPSA-EUR France); pulled in + // parallel so it never extends the critical path. Used to + // anchor overflow-graph action pins on substations. + api.getVoltageLevelSubstations().catch(() => ({ mapping: {} as Record })), ]); setBranches(branchRes.branches); @@ -827,6 +916,7 @@ function App() { } diagrams.ingestBaseDiagram(diagramRaw, vlRes.voltage_levels.length); + setVlToSubstation(vlSubRes.mapping || {}); committedNetworkPathRef.current = networkPath; interactionLogger.record('config_loaded', buildConfigInteractionDetails()); } catch (err: unknown) { @@ -1001,6 +1091,20 @@ function App() { handleVlDoubleClick(selectedActionId || '', vlName); }, [handleVlDoubleClick, selectedActionId]); + // Double-click on an action pin in the overflow graph drills into + // that substation's SLD on the post-action ('action') sub-tab. + // Guarded on the action being known to the analysis result — + // double-clicks on stale or unknown pins are silently ignored. + const handleOverflowPinDoubleClick = useCallback((actionId: string, substation: string) => { + if (!actionId || !substation) return; + const knownAction = !!result?.actions?.[actionId]; + if (!knownAction) return; + interactionLogger.record('overflow_pin_double_clicked', { + actionId, substation, + }); + handleVlDoubleClick(actionId, substation, 'action'); + }, [handleVlDoubleClick, result?.actions]); + const handleCancelDialog = useCallback(() => { // Cancelling a "Change Network?" dialog must roll back the // optimistic networkPath update done by requestNetworkPathChange, @@ -1244,6 +1348,8 @@ function App() { onOverlaySldTabChange={handleOverlaySldTabChange} voltageLevels={voltageLevels} onVlOpen={handleVlOpen} + onOverflowPinPreview={handlePinPreview} + onOverflowPinDoubleClick={handleOverflowPinDoubleClick} networkPath={networkPath} layoutPath={layoutPath} onOpenSettings={handleOpenSettings} @@ -1270,6 +1376,10 @@ function App() { onSimulateUnsimulatedAction={handleSimulateUnsimulatedAction} showVoltageLevelNames={showVoltageLevelNames} onToggleVoltageLevelNames={handleToggleVoltageLevelNames} + overflowPins={allOverflowPins} + overflowPinsEnabled={overflowPinsEnabled} + overflowPinsAvailable={overflowPinsAvailable} + onOverflowPinsToggle={setOverflowPinsEnabled} /> diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 637e34e7..5ff5b2e6 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -66,6 +66,17 @@ export const api = { ); return response.data; }, + /** + * Return ``{vl_id: substation_id}`` for every voltage level. + * Used to anchor action-overview pins on the overflow graph + * (which uses substation names as node identifiers). + */ + getVoltageLevelSubstations: async (): Promise<{ mapping: Record }> => { + const response = await axios.get<{ mapping: Record }>( + `${API_BASE_URL}/api/voltage-level-substations` + ); + return response.data; + }, // NOTE: the FastAPI backend always serialises the SVG as a raw // XML string, so these axios methods narrow `DiagramData.svg` to // `string` at the boundary. The wider `string | SVGSVGElement` diff --git a/frontend/src/components/SldOverlay.tsx b/frontend/src/components/SldOverlay.tsx index 8fdfc0a5..d0ddc6c0 100644 --- a/frontend/src/components/SldOverlay.tsx +++ b/frontend/src/components/SldOverlay.tsx @@ -825,7 +825,18 @@ const SldOverlay: React.FC = ({
{(['n', 'n-1', 'action'] as SldTab[]).filter(tabMode => { if (tabMode === 'n-1') return !!n1Diagram; - if (tabMode === 'action') return !!actionDiagram; + // The SLD's "action" sub-tab depends on the + // overlay being scoped to an action — NOT on + // the main-window NAD already being on the + // action variant. The overflow-graph pin + // double-click opens the SLD overlay with + // ``vlOverlay.actionId`` set BEFORE any + // top-level action diagram has been loaded; + // hiding the tab in that case would strand + // the operator on a card-less variant view. + if (tabMode === 'action') { + return !!actionDiagram || !!vlOverlay.actionId; + } return true; // always show N }).map(tabMode => (
+ {/* Pin overlay toggle. Only enabled once + the action analysis is complete (the + gate is owned by App.tsx through + `overflowPinsAvailable`). */} + {onOverflowPinsToggle && (() => { + const pinsDisabled = !overflowPinsAvailable; + const tip = pinsDisabled + ? 'Run "Analyze & Suggest" to compute action pins' + : (overflowPinsEnabled + ? 'Hide action pins on the overflow graph' + : 'Show action pins on the overflow graph'); + return ( + + ); + })()} ); })()} {result?.pdf_url ? (