// 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. import { describe, it, expect } from 'vitest'; import { cloneBaseSvg, applyPatchToClone } from './svgPatch'; import type { DiagramPatch, MetadataIndex, EdgeMeta } from '../types'; // // Synthetic SVG fixture — mirrors the structure pypowsybl emits for a // small NAD with two lines (A, B) and one set of edge-info labels per // terminal. Just enough structure to exercise the patch module: // - element IDs on edges and edge-info wrappers // - children inside the edge-info so we can assert the label // swap happened. // const buildBaseSvg = (): SVGSVGElement => { const parser = new DOMParser(); const doc = parser.parseFromString( ` 111 -111 222 -222 `, 'image/svg+xml', ); return doc.documentElement as unknown as SVGSVGElement; }; const buildMetaIndex = (): MetadataIndex => { const edgeA: EdgeMeta = { equipmentId: 'LINE_A', svgId: 'nad-l-LINE_A', node1: 'VL_1', node2: 'VL_2', edgeInfo1: { svgId: 'nad-ei-LINE_A-1' }, edgeInfo2: { svgId: 'nad-ei-LINE_A-2' }, }; const edgeB: EdgeMeta = { equipmentId: 'LINE_B', svgId: 'nad-l-LINE_B', node1: 'VL_2', node2: 'VL_3', edgeInfo1: { svgId: 'nad-ei-LINE_B-1' }, edgeInfo2: { svgId: 'nad-ei-LINE_B-2' }, }; return { nodesByEquipmentId: new Map(), nodesBySvgId: new Map(), edgesByEquipmentId: new Map([ ['LINE_A', edgeA], ['LINE_B', edgeB], ]), edgesByNode: new Map(), }; }; const emptyAbsoluteFlows = (overrides: Record = {}) => ({ p1: { ...overrides }, p2: {}, q1: {}, q2: {}, vl1: {}, vl2: {}, }); describe('cloneBaseSvg', () => { it('returns an independent copy — mutating the clone does not touch the source', () => { const base = buildBaseSvg(); const baseAEl = base.querySelector('#nad-l-LINE_A')!; expect(baseAEl.classList.contains('nad-disconnected')).toBe(false); const clone = cloneBaseSvg(base); clone.querySelector('#nad-l-LINE_A')!.classList.add('nad-disconnected'); expect(baseAEl.classList.contains('nad-disconnected')).toBe(false); }); }); describe('applyPatchToClone', () => { it('marks disconnected edges with .nad-disconnected', () => { const base = buildBaseSvg(); const meta = buildMetaIndex(); const patch: DiagramPatch = { patchable: true, contingency_id: 'LINE_A', lf_converged: true, lf_status: 'CONVERGED', disconnected_edges: ['LINE_A'], absolute_flows: emptyAbsoluteFlows(), }; const clone = cloneBaseSvg(base); const { svgElement } = applyPatchToClone(clone, meta, patch); expect(svgElement.querySelector('#nad-l-LINE_A')!.classList.contains('nad-disconnected')).toBe(true); expect(svgElement.querySelector('#nad-l-LINE_B')!.classList.contains('nad-disconnected')).toBe(false); }); it('overwrites absolute flow labels on both terminals and backs up originals', () => { const base = buildBaseSvg(); const meta = buildMetaIndex(); const patch: DiagramPatch = { patchable: true, contingency_id: 'LINE_A', lf_converged: true, lf_status: 'CONVERGED', disconnected_edges: [], absolute_flows: { p1: { LINE_B: 350 }, p2: { LINE_B: -348 }, q1: {}, q2: {}, vl1: {}, vl2: {}, }, }; const clone = cloneBaseSvg(base); applyPatchToClone(clone, meta, patch); const t1 = clone.querySelector('#nad-ei-LINE_B-1 text')!; const t2 = clone.querySelector('#nad-ei-LINE_B-2 text')!; expect(t1.textContent).toBe('350'); expect(t2.textContent).toBe('-348'); expect(t1.getAttribute('data-patched-flow')).toBe('222'); expect(t2.getAttribute('data-patched-flow')).toBe('-222'); // LINE_A text should be untouched by the patch. expect(clone.querySelector('#nad-ei-LINE_A-1 text')!.textContent).toBe('111'); }); it('is idempotent: re-applying a different patch restores then re-writes', () => { const base = buildBaseSvg(); const meta = buildMetaIndex(); const first: DiagramPatch = { patchable: true, contingency_id: 'LINE_A', lf_converged: true, lf_status: 'CONVERGED', disconnected_edges: ['LINE_A'], absolute_flows: { p1: { LINE_B: 350 }, p2: {}, q1: {}, q2: {}, vl1: {}, vl2: {}, }, }; const second: DiagramPatch = { patchable: true, contingency_id: 'LINE_B', lf_converged: true, lf_status: 'CONVERGED', disconnected_edges: ['LINE_B'], absolute_flows: { p1: { LINE_A: 50 }, p2: {}, q1: {}, q2: {}, vl1: {}, vl2: {}, }, }; const clone = cloneBaseSvg(base); applyPatchToClone(clone, meta, first); applyPatchToClone(clone, meta, second); // Only LINE_B is marked disconnected after the second patch. expect(clone.querySelector('#nad-l-LINE_A')!.classList.contains('nad-disconnected')).toBe(false); expect(clone.querySelector('#nad-l-LINE_B')!.classList.contains('nad-disconnected')).toBe(true); // LINE_B edge-info label restored to its original before the // second patch; LINE_A edge-info label now overwritten. expect(clone.querySelector('#nad-ei-LINE_B-1 text')!.textContent).toBe('222'); expect(clone.querySelector('#nad-ei-LINE_A-1 text')!.textContent).toBe('50'); }); it('is a no-op when patchable is false (caller is expected to fall back)', () => { const base = buildBaseSvg(); const meta = buildMetaIndex(); const patch: DiagramPatch = { patchable: false, reason: 'switch_state_changed', action_id: 'ACT_X', lf_converged: true, lf_status: 'CONVERGED', }; const clone = cloneBaseSvg(base); const before = clone.outerHTML; applyPatchToClone(clone, meta, patch); expect(clone.outerHTML).toBe(before); }); it('splices a VL node subtree and rewrites the sub-diagram id to the main svgId', () => { const base = buildBaseSvg(); const meta = buildMetaIndex(); // Main diagram has this VL under svgId `nad-vl-VL_1`. const vlNode = document.createElementNS('http://www.w3.org/2000/svg', 'g'); vlNode.setAttribute('id', 'nad-vl-VL_1'); vlNode.innerHTML = ''; base.appendChild(vlNode); meta.nodesByEquipmentId.set('VL_1', { equipmentId: 'VL_1', svgId: 'nad-vl-VL_1', x: 0, y: 0, }); // Backend emits the fragment with the SUB-diagram's svgId // (`nad-vl-0`). The splice must rewrite it to the main // svgId so later idMap lookups (halo clone, delta visuals) // keep working. const patch: DiagramPatch = { patchable: true, action_id: 'node_merging_VL_1', lf_converged: true, lf_status: 'CONVERGED', disconnected_edges: [], absolute_flows: emptyAbsoluteFlows(), vl_subtrees: { VL_1: { node_svg: '', node_sub_svg_id: 'nad-vl-0', }, }, }; const clone = cloneBaseSvg(base); applyPatchToClone(clone, meta, patch); // Patched element is now under the MAIN svgId, not the sub svgId. const patched = clone.querySelector('#nad-vl-VL_1 circle'); expect(patched).not.toBeNull(); expect(patched!.getAttribute('data-version')).toBe('patched'); expect(patched!.getAttribute('r')).toBe('10'); // The sub-diagram svgId MUST NOT leak into the final DOM. expect(clone.querySelector('#nad-vl-0')).toBeNull(); // Base stays pristine — clone happens before splice. const baseCircle = base.querySelector('#nad-vl-VL_1 circle')!; expect(baseCircle.getAttribute('data-version')).toBe('base'); }); it('splices edge fragments with their sub-diagram ids rewritten to main edge svgIds', () => { const base = buildBaseSvg(); const meta = buildMetaIndex(); // Base has LINE_A at nad-l-LINE_A (from buildMetaIndex fixture). const patch: DiagramPatch = { patchable: true, action_id: 'coupling_VL_1', lf_converged: true, lf_status: 'CONVERGED', disconnected_edges: [], absolute_flows: emptyAbsoluteFlows(), vl_subtrees: { VL_1: { node_svg: '', node_sub_svg_id: 'nad-vl-0', edge_fragments: { LINE_A: { svg: '', sub_svg_id: 'nad-l-sub-0', }, }, }, }, }; // Register the VL so the splice finds it. meta.nodesByEquipmentId.set('VL_1', { equipmentId: 'VL_1', svgId: 'nad-vl-0-base', x: 0, y: 0, }); // And add a node with that main svgId to the base so the splice // has something to replace. const vlNode = document.createElementNS('http://www.w3.org/2000/svg', 'g'); vlNode.setAttribute('id', 'nad-vl-0-base'); base.appendChild(vlNode); const clone = cloneBaseSvg(base); applyPatchToClone(clone, meta, patch); // LINE_A's edge is now under the MAIN edge svgId (nad-l-LINE_A), // not the sub-diagram id. const patchedEdge = clone.querySelector('#nad-l-LINE_A path'); expect(patchedEdge).not.toBeNull(); expect(patchedEdge!.getAttribute('data-source')).toBe('patched'); expect(clone.querySelector('#nad-l-sub-0')).toBeNull(); }); it('is a no-op for vl_subtrees entries whose target svgId is missing from the clone', () => { const base = buildBaseSvg(); const meta = buildMetaIndex(); const patch: DiagramPatch = { patchable: true, action_id: 'ghost_vl_action', lf_converged: true, lf_status: 'CONVERGED', disconnected_edges: [], absolute_flows: emptyAbsoluteFlows(), vl_subtrees: { GHOST_VL: { node_svg: '', node_sub_svg_id: 'nad-vl-0', }, }, }; const clone = cloneBaseSvg(base); expect(() => applyPatchToClone(clone, meta, patch)).not.toThrow(); }); it('splices multiple VLs in a single patch call', () => { const base = buildBaseSvg(); const meta = buildMetaIndex(); // Two VLs with distinct main svgIds. for (const [vl, svgId, ver] of [ ['VL_A', 'nad-vl-10', 'A'], ['VL_B', 'nad-vl-11', 'B'], ] as const) { const el = document.createElementNS('http://www.w3.org/2000/svg', 'g'); el.setAttribute('id', svgId); el.innerHTML = ``; base.appendChild(el); meta.nodesByEquipmentId.set(vl, { equipmentId: vl, svgId, x: 0, y: 0 }); } const patch: DiagramPatch = { patchable: true, action_id: 'multi_vl_coupling', lf_converged: true, lf_status: 'CONVERGED', disconnected_edges: [], absolute_flows: emptyAbsoluteFlows(), vl_subtrees: { VL_A: { node_svg: '', node_sub_svg_id: 'nad-vl-0', }, VL_B: { node_svg: '', node_sub_svg_id: 'nad-vl-0', }, }, }; const clone = cloneBaseSvg(base); applyPatchToClone(clone, meta, patch); // Each VL lands under its OWN main svgId despite the two // fragments sharing `nad-vl-0` in their sub-diagrams. expect(clone.querySelector('#nad-vl-10 circle')!.getAttribute('data-version')).toBe('patched-A'); expect(clone.querySelector('#nad-vl-11 circle')!.getAttribute('data-version')).toBe('patched-B'); }); it('skips edge_fragments whose equipmentId is missing from the base metadata', () => { const base = buildBaseSvg(); const meta = buildMetaIndex(); const vlNode = document.createElementNS('http://www.w3.org/2000/svg', 'g'); vlNode.setAttribute('id', 'nad-vl-42'); base.appendChild(vlNode); meta.nodesByEquipmentId.set('VL_X', { equipmentId: 'VL_X', svgId: 'nad-vl-42', x: 0, y: 0 }); const patch: DiagramPatch = { patchable: true, action_id: 'coupling_VL_X', lf_converged: true, lf_status: 'CONVERGED', disconnected_edges: [], absolute_flows: emptyAbsoluteFlows(), vl_subtrees: { VL_X: { node_svg: '', node_sub_svg_id: 'nad-vl-0', edge_fragments: { GHOST_EDGE: { svg: '', sub_svg_id: 'nad-l-99' }, }, }, }, }; const clone = cloneBaseSvg(base); // Must not throw and must not insert the ghost edge under // any id — the base had no matching edge. expect(() => applyPatchToClone(clone, meta, patch)).not.toThrow(); expect(clone.querySelector('#nad-l-99')).toBeNull(); }); it('idempotency: re-applying the same vl_subtrees patch twice leaves the DOM identical', () => { const base = buildBaseSvg(); const meta = buildMetaIndex(); const vlNode = document.createElementNS('http://www.w3.org/2000/svg', 'g'); vlNode.setAttribute('id', 'nad-vl-77'); vlNode.innerHTML = ''; base.appendChild(vlNode); meta.nodesByEquipmentId.set('VL_IDEM', { equipmentId: 'VL_IDEM', svgId: 'nad-vl-77', x: 0, y: 0 }); const patch: DiagramPatch = { patchable: true, action_id: 'act', lf_converged: true, lf_status: 'CONVERGED', disconnected_edges: [], absolute_flows: emptyAbsoluteFlows(), vl_subtrees: { VL_IDEM: { node_svg: '', node_sub_svg_id: 'nad-vl-0', }, }, }; const clone = cloneBaseSvg(base); applyPatchToClone(clone, meta, patch); const html1 = clone.querySelector('#nad-vl-77')!.outerHTML; applyPatchToClone(clone, meta, patch); const html2 = clone.querySelector('#nad-vl-77')!.outerHTML; expect(html2).toBe(html1); }); it('ignores unknown equipment ids in disconnected_edges without throwing', () => { const base = buildBaseSvg(); const meta = buildMetaIndex(); const patch: DiagramPatch = { patchable: true, contingency_id: 'GHOST_LINE', lf_converged: true, lf_status: 'CONVERGED', disconnected_edges: ['GHOST_LINE'], absolute_flows: emptyAbsoluteFlows(), }; const clone = cloneBaseSvg(base); expect(() => applyPatchToClone(clone, meta, patch)).not.toThrow(); }); });