# 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 # This file is part of Co-Study4Grid a Power Grid Study tool Assistant Interface to help solve contigencies for a grid state under study. """Unit tests for the DiagramMixin helpers that drive the svgPatch DOM-recycling path: - ``_compute_vl_topology_diff`` — which VLs have different bus counts between the action-variant snapshot and the live N-1 network? Drives the decision to splice vs. fall back to the full NAD. - ``_get_disconnected_branches_from_snapshot`` — the ``disconnected_edges`` list that the client renders with the `nad-disconnected` class (dashed line), covering the N-1 contingency + any disco_* / reco_* action impact. Both helpers are pure / staticmethod-like — they accept pandas DataFrames (captured snapshots from the action variant) and a live N-1 `Network`, so we exercise them directly with lightweight mocks instead of standing up a full pypowsybl stack. See docs/performance/history/svg-dom-recycling.md for the architecture. """ from __future__ import annotations import pandas as pd import pytest from unittest.mock import MagicMock from expert_backend.services.diagram_mixin import ( ActionResultUnavailableError, DiagramMixin, ) from expert_backend.services.recommender_service import RecommenderService def _bus_snap(counts_by_vl: dict[str, int]) -> pd.DataFrame: """Build an action-variant `get_buses(attributes=['voltage_level_id'])` lookalike: one row per bus, `voltage_level_id` column naming the VL the bus belongs to. `counts_by_vl` declares how many buses each VL hosts.""" rows = [] bus_idx = 0 for vl, n in counts_by_vl.items(): for _ in range(n): rows.append({"bus_id": f"bus-{bus_idx}", "voltage_level_id": vl}) bus_idx += 1 df = pd.DataFrame(rows) if rows: df = df.set_index("bus_id") return df def _n1_network_mock(counts_by_vl: dict[str, int]) -> MagicMock: net = MagicMock() net.get_buses.return_value = _bus_snap(counts_by_vl) return net # --------------------------------------------------------------------------- # _compute_vl_topology_diff # --------------------------------------------------------------------------- class TestComputeVlTopologyDiff: def test_returns_none_when_snapshot_missing(self): """A failed snapshot capture must be treated conservatively — the caller must fall back to the full NAD rather than splice with unknown topology.""" n1 = _n1_network_mock({"VL_A": 1}) result = DiagramMixin._compute_vl_topology_diff(None, n1) assert result is None def test_returns_none_when_network_raises(self): """If pypowsybl raises during `get_buses`, we return None and let the caller fall back. No half-computed diff is acceptable.""" action_snap = _bus_snap({"VL_A": 1}) n1 = MagicMock() n1.get_buses.side_effect = RuntimeError("pypowsybl exploded") result = DiagramMixin._compute_vl_topology_diff(action_snap, n1) assert result is None def test_empty_list_when_counts_match(self): """Actions that only flip line breakers or shift flows leave every VL's bus count untouched → patch path uses empty vl_subtrees (no VL re-rendering needed).""" action_snap = _bus_snap({"VL_A": 2, "VL_B": 1}) n1 = _n1_network_mock({"VL_A": 2, "VL_B": 1}) assert DiagramMixin._compute_vl_topology_diff(action_snap, n1) == [] def test_reports_vl_when_action_merges_two_buses_into_one(self): """Node-merging on VL_A: action snapshot has 1 bus, N-1 has 2.""" action_snap = _bus_snap({"VL_A": 1, "VL_B": 1}) n1 = _n1_network_mock({"VL_A": 2, "VL_B": 1}) result = DiagramMixin._compute_vl_topology_diff(action_snap, n1) assert result == ["VL_A"] def test_reports_vl_when_action_splits_one_bus_into_two(self): """Node-splitting on VL_B: action snapshot has 2 buses, N-1 has 1.""" action_snap = _bus_snap({"VL_A": 1, "VL_B": 2}) n1 = _n1_network_mock({"VL_A": 1, "VL_B": 1}) result = DiagramMixin._compute_vl_topology_diff(action_snap, n1) assert result == ["VL_B"] def test_reports_multiple_vls_when_coupling_impacts_several(self): """A compound action that toggles couplers across two VLs.""" action_snap = _bus_snap({"VL_A": 2, "VL_B": 2, "VL_C": 1}) n1 = _n1_network_mock({"VL_A": 1, "VL_B": 1, "VL_C": 1}) result = DiagramMixin._compute_vl_topology_diff(action_snap, n1) assert set(result) == {"VL_A", "VL_B"} def test_reports_vl_present_only_in_one_side(self): """Asymmetric VL membership → diff (ghost VL indicates a bus split/merge collapsing an entire VL's electrical presence).""" action_snap = _bus_snap({"VL_A": 1}) n1 = _n1_network_mock({"VL_A": 1, "VL_B": 1}) result = DiagramMixin._compute_vl_topology_diff(action_snap, n1) assert result == ["VL_B"] # --------------------------------------------------------------------------- # _get_disconnected_branches_from_snapshot # --------------------------------------------------------------------------- def _conn_snap(rows: list[tuple[str, bool, bool]]) -> pd.DataFrame: """Build a `get_lines(attributes=['connected1','connected2'])` lookalike. `rows` is a list of (line_id, connected1, connected2) tuples.""" df = pd.DataFrame( [{"id": r[0], "connected1": r[1], "connected2": r[2]} for r in rows] ) if rows: df = df.set_index("id") return df class TestGetDisconnectedBranchesFromSnapshot: def test_empty_when_every_branch_is_connected(self): """PST / redispatch / flow-only actions don't flip breakers.""" lines = _conn_snap([("LINE_A", True, True), ("LINE_B", True, True)]) trafos = _conn_snap([("T_1", True, True)]) result = DiagramMixin._get_disconnected_branches_from_snapshot(lines, trafos) assert result == [] def test_lists_n1_contingency(self): """Contingency line (disconnected in N-1) remains disconnected post-action and must render dashed on the action tab.""" lines = _conn_snap([("CONTINGENCY", False, True), ("LINE_B", True, True)]) trafos = _conn_snap([]) result = DiagramMixin._get_disconnected_branches_from_snapshot(lines, trafos) assert result == ["CONTINGENCY"] def test_lists_contingency_plus_disco_action_target(self): """disco_* adds a new open branch; both must render dashed.""" lines = _conn_snap([ ("CONTINGENCY", False, True), ("LINE_B", True, True), ("DISCO_TARGET", True, False), ]) trafos = _conn_snap([]) result = DiagramMixin._get_disconnected_branches_from_snapshot(lines, trafos) assert set(result) == {"CONTINGENCY", "DISCO_TARGET"} def test_reco_excludes_the_reconnected_line(self): """reco_* on the contingency reconnects it; the action tab must drop the dashed class on that line. Empty list expected.""" lines = _conn_snap([ ("CONTINGENCY", True, True), # reco_* restored both terminals ("LINE_B", True, True), ]) trafos = _conn_snap([]) result = DiagramMixin._get_disconnected_branches_from_snapshot(lines, trafos) assert result == [] def test_covers_both_lines_and_transformers(self): """2-winding transformers may also carry the contingency (rare but possible). Must be scanned alongside lines.""" lines = _conn_snap([("LINE_A", True, True)]) trafos = _conn_snap([("T_OPEN", False, True)]) result = DiagramMixin._get_disconnected_branches_from_snapshot(lines, trafos) assert result == ["T_OPEN"] def test_tolerates_none_snapshots(self): """Snapshot capture can fail (pypowsybl quirk / empty network). Both None → empty list; partial None → scan the side we have.""" result = DiagramMixin._get_disconnected_branches_from_snapshot(None, None) assert result == [] lines = _conn_snap([("LINE_A", False, True)]) result = DiagramMixin._get_disconnected_branches_from_snapshot(lines, None) assert result == ["LINE_A"] def test_tolerates_empty_dataframes(self): """A study with no transformers (or no lines) must not raise. Keeps the helper safe on micro-grids used in unit tests.""" empty = pd.DataFrame(columns=["connected1", "connected2"]) result = DiagramMixin._get_disconnected_branches_from_snapshot(empty, empty) assert result == [] def test_tolerates_raising_dataframe_access(self): """If reading `connected1`/`connected2` raises (e.g. dtype surprises from a future pypowsybl release), the helper swallows per-frame exceptions and returns what it could compute from the other frame — correctness on best-effort basis.""" bad = MagicMock() # Make `.index` non-empty so we enter the try-block, then let # column access raise. bad.index = pd.Index(["x"]) bad.__getitem__.side_effect = RuntimeError("column gone") # Use Python's len() protocol via a wrapper MagicMock with # __len__ configured. bad.__len__ = lambda _self=bad: 1 good = _conn_snap([("LINE_A", False, True)]) result = DiagramMixin._get_disconnected_branches_from_snapshot(bad, good) assert result == ["LINE_A"] # --------------------------------------------------------------------------- # Integration with the patch-endpoint response shape # --------------------------------------------------------------------------- class TestPatchPayloadShape: """The patch payload is consumed by a strict client-side type (`DiagramPatch` in frontend/src/types.ts). These tests lock the shape contract at the Python boundary to catch accidental renames / deletions before they ship.""" def test_n1_patch_required_fields(self): """Every field the client unconditionally reads must be on every `get_n1_diagram_patch` response — enforce via a spec list rather than wait for a Vitest failure.""" required = { "patchable", "contingency_id", "lf_converged", "lf_status", "disconnected_edges", } # Illustrative minimal payload — matches what # `get_n1_diagram_patch` returns. payload = { "patchable": True, "contingency_id": "LINE_A", "lf_converged": True, "lf_status": "CONVERGED", "disconnected_edges": ["LINE_A"], "absolute_flows": {"p1": {}, "p2": {}, "q1": {}, "q2": {}, "vl1": {}, "vl2": {}}, "lines_overloaded": [], "lines_overloaded_rho": [], "flow_deltas": {}, "reactive_flow_deltas": {}, "asset_deltas": {}, "meta": {"base_state": "N", "elapsed_ms": 42}, } missing = required - payload.keys() assert not missing, f"missing required N-1 patch fields: {missing}" def test_action_patch_vl_subtree_entry_shape(self): """`vl_subtrees[vlId]` entries carry node_svg + node_sub_svg_id; edge_fragments is optional but each item has svg + sub_svg_id. Locks the client-rewrite contract — the sub_svg_id is what the frontend replaces with the main-diagram svgId.""" entry = { "node_svg": "", "node_sub_svg_id": "nad-vl-0", "edge_fragments": { "LINE_A": {"svg": "", "sub_svg_id": "nad-l-3"}, }, } assert "node_svg" in entry assert "node_sub_svg_id" in entry edge = entry["edge_fragments"]["LINE_A"] assert {"svg", "sub_svg_id"} <= edge.keys() @pytest.mark.parametrize("field", [ "patchable", "action_id", "reason", "lf_converged", "lf_status", ]) def test_unpatchable_response_keeps_slim_shape(self, field): """The `patchable: false` fallback payload carries only the fields the client branches on. Prevents accidental leakage of heavy fields when the extractor fails.""" payload = { "patchable": False, "reason": "vl_topology_changed", "action_id": "node_splitting_X", "lf_converged": True, "lf_status": "CONVERGED", "non_convergence": None, } assert field in payload class TestActionResultUnavailable: """After a session reload (and for any manually-added action) the backend has no cached observation for the action. The action-variant *patch* endpoint must soft-fail with ``patchable: false`` — the contract the frontend already handles — instead of raising a 400, and ``_require_action`` must raise the dedicated ``ActionResultUnavailableError`` so the API layer can log the expected condition quietly. See docs/features/save-results.md (post-reload action fallback).""" @pytest.fixture def service(self): return RecommenderService() def test_patch_soft_fails_when_no_analysis_result(self, service): service._last_result = None payload = service.get_action_variant_diagram_patch("disco_LINE_X") assert payload["patchable"] is False assert payload["reason"] == "no-analysis-result" assert payload["action_id"] == "disco_LINE_X" def test_patch_soft_fails_when_action_not_in_last_result(self, service): # A result exists but this action id isn't one the recommender # produced (e.g. a manually-added action). service._last_result = {"prioritized_actions": {"some_other_action": {}}} payload = service.get_action_variant_diagram_patch("manual_LINE_Y") assert payload["patchable"] is False assert payload["reason"] == "action-not-in-last-result" assert payload["action_id"] == "manual_LINE_Y" def test_require_action_raises_dedicated_error_when_no_result(self, service): service._last_result = None with pytest.raises(ActionResultUnavailableError, match="No analysis result"): service._require_action("disco_LINE_X") def test_require_action_raises_dedicated_error_when_action_missing(self, service): service._last_result = {"prioritized_actions": {"known_action": {}}} with pytest.raises(ActionResultUnavailableError, match="not found in last analysis result"): service._require_action("unknown_action") def test_dedicated_error_is_a_value_error(self): # Subclassing ValueError keeps every existing `except ValueError` # / `except Exception` boundary returning HTTP 400 unchanged — # the dedicated type only adds a hook for quiet logging. assert issubclass(ActionResultUnavailableError, ValueError)