Co-Study4Grid / expert_backend /tests /test_overflow_html_dim_logic.py
github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
32.1 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
"""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 pathlib import Path
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")
# Prod / load / quiet nodes — carry the same prod_or_load + value
# attributes upstream `build_nodes` assigns. The viewer layers
# filter on these.
g.add_node("PROD_BIG", shape="oval", prod_or_load="prod", value="42.0",
style="filled", fillcolor="coral")
g.add_node("LOAD_BIG", shape="oval", prod_or_load="load", value="-30.0",
style="filled", fillcolor="lightblue")
g.add_node("LOAD_TINY", shape="oval", prod_or_load="load", value="0.4",
style="filled", fillcolor="#ffffed") # below 1 MW floor
g.add_node("LOAD_ZERO", shape="oval", prod_or_load="load", value="0.0",
style="filled", fillcolor="#ffffed") # exact zero balance
# 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"}
class TestProdLoadValueLayers:
"""Coverage for the value-based ``node:prod`` / ``node:load`` layers
introduced alongside the ``prod_or_load`` attribute upstream
``build_nodes`` writes on every node.
Contract:
* Only nodes whose ``prod_or_load`` matches the layer kind AND
whose ``abs(value)`` is at least 1 MW count — the white-coloured
zero-balance nodes (``prod_or_load='load'`` with ``value='0.0'``)
must NOT leak into the Consumption layer.
* Both layers live in the *Individual entities properties* section
so they group with Hubs / Overloads / Reconnectable in the
viewer's sidebar.
"""
def test_production_layer_contains_only_prod_nodes_above_floor(self):
_, model = _build_html_and_model()
layer = _layers_by_key(model).get("node:prod")
assert layer is not None, "node:prod layer missing"
assert set(layer["nodes"]) == {"PROD_BIG"}
assert layer["edges"] == []
def test_consumption_layer_excludes_zero_balance_and_subfloor_nodes(self):
_, model = _build_html_and_model()
layer = _layers_by_key(model).get("node:load")
assert layer is not None, "node:load layer missing"
# LOAD_BIG passes the floor; LOAD_TINY (0.4 MW) and LOAD_ZERO
# (0.0 MW) are filtered out by the 1 MW threshold.
node_set = set(layer["nodes"])
assert "LOAD_BIG" in node_set
assert "LOAD_TINY" not in node_set
assert "LOAD_ZERO" not in node_set
# Prod nodes never bleed into the load layer.
assert "PROD_BIG" not in node_set
# No edges on a value-based node layer.
assert layer["edges"] == []
def test_value_layers_group_under_individual_entities_section(self):
_, model = _build_html_and_model()
layers = _layers_by_key(model)
for key in ("node:prod", "node:load"):
assert layers[key]["section"] == "Individual entities properties"
# ---------------------------------------------------------------------
# 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."""
# Resolve relative to the project root so the test works on any
# checkout (CI, dev machine, container) — not just the original
# author's home dir. Test file lives at
# ``<root>/expert_backend/tests/test_overflow_html_dim_logic.py``,
# so the project root is two parents above this file.
PROJECT_ROOT = Path(__file__).resolve().parents[2]
HTML_PATH = str(
PROJECT_ROOT
/ "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 ``<title>`` says. After the fix, every SVG edge's
title and data-* attributes must agree."""
import html as _html_mod
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 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>([^<]*)</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