Co-Study4Grid / frontend /src /utils /svgPatch.test.ts
github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
17.2 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
// 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
// - <text> children inside the edge-info so we can assert the label
// swap happened.
//
const buildBaseSvg = (): SVGSVGElement => {
const parser = new DOMParser();
const doc = parser.parseFromString(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<g id="nad-l-LINE_A"><path d="M0 0 L10 10" stroke="#000"/></g>
<g id="nad-l-LINE_B"><path d="M0 10 L10 20" stroke="#000"/></g>
<g id="nad-ei-LINE_A-1"><text>111</text></g>
<g id="nad-ei-LINE_A-2"><text>-111</text></g>
<g id="nad-ei-LINE_B-1"><text>222</text></g>
<g id="nad-ei-LINE_B-2"><text>-222</text></g>
</svg>`,
'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<string, number> = {}) => ({
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 = '<circle r="5" data-version="base"/>';
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: '<g id="nad-vl-0"><circle r="10" data-version="patched"/></g>',
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: '<g id="nad-vl-0"><circle r="7"/></g>',
node_sub_svg_id: 'nad-vl-0',
edge_fragments: {
LINE_A: {
svg: '<g id="nad-l-sub-0"><path d="M0 0 L100 100" data-source="patched"/></g>',
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: '<g id="nad-vl-0"><circle r="1"/></g>',
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 = `<circle r="1" data-version="base-${ver}"/>`;
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: '<g id="nad-vl-0"><circle r="3" data-version="patched-A"/></g>',
node_sub_svg_id: 'nad-vl-0',
},
VL_B: {
node_svg: '<g id="nad-vl-0"><circle r="4" data-version="patched-B"/></g>',
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: '<g id="nad-vl-0"/>',
node_sub_svg_id: 'nad-vl-0',
edge_fragments: {
GHOST_EDGE: { svg: '<g id="nad-l-99"/>', 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 = '<circle r="1"/>';
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: '<g id="nad-vl-0"><circle r="5" data-v="x"/></g>',
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();
});
});