Co-Study4Grid / frontend /src /utils /svg /overflowOverlayRender.test.ts
github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
13.1 kB
// Copyright (c) 2025-2026, RTE (https://www.rte-france.com)
// SPDX-License-Identifier: MPL-2.0
/**
* End-to-end render test for the overflow-graph iframe overlay JS.
*
* The overlay is a Python f-string in
* ``expert_backend/services/overflow_overlay.py`` — the unit tests in
* ``test_overflow_overlay.py`` only assert that the produced JS *contains*
* the right keywords. This test goes one layer deeper: it loads the
* overlay JS into a jsdom document, builds a synthetic graphviz-style
* SVG, simulates a ``cs4g:pins`` postMessage, and asserts that pin glyphs
* actually land in the DOM.
*
* This is the regression guard for the user-reported bug "no pins are
* rendered on the overflow analysis tab" — without it, a typo / scope
* issue in the overlay's render() loop is invisible to the rest of the
* test suite (the Python tests only check keywords).
*/
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, it, expect, beforeEach, vi } from 'vitest';
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..');
const OVERLAY_PY = resolve(REPO_ROOT, 'expert_backend/services/overflow_overlay.py');
const PIN_GLYPH_JS = resolve(REPO_ROOT, 'frontend/src/utils/svg/pinGlyph.js');
/**
* Extract the inlined overlay <script> body from the Python source.
*
* The Python file builds the JS via an f-string returned by
* ``_build_overlay_block``. We pull the body of the first
* ``<script id="cs4g-overlay-script">…</script>`` block out by
* regex — same shape ``inject_overlay`` ships at runtime — and
* strip Python's ``{{`` / ``}}`` doubling so the result is valid JS.
*/
function loadOverlayScript(): string {
const py = readFileSync(OVERLAY_PY, 'utf-8');
const m = py.match(/<script id="cs4g-overlay-script">([\s\S]*?)<\/script>/);
if (!m) throw new Error('cs4g-overlay-script block not found');
let body = m[1];
// The Python f-string substitutes ``{pin_glyph_js}`` with the
// contents of ``frontend/src/utils/svg/pinGlyph.js`` (ESM
// ``export`` keywords stripped). Reproduce that substitution
// BEFORE we collapse ``{{`` / ``}}`` into single braces, so the
// glyph file's own braces are preserved verbatim.
let glyph = readFileSync(PIN_GLYPH_JS, 'utf-8');
glyph = glyph.replace(/^export\s+/gm, '');
body = body.replace('{pin_glyph_js}', glyph);
// Now collapse the remaining f-string brace doubling.
body = body.replace(/\{\{/g, '{').replace(/\}\}/g, '}');
return body;
}
interface SyntheticPoint { x: number; y: number }
/**
* Build a synthetic graphviz-style SVG with two named nodes and a
* curved edge whose path runs from src to tgt. jsdom does not
* implement getBBox / getCTM / getTotalLength / getPointAtLength, so
* we monkey-patch them with deterministic stubs keyed off element
* attributes — enough for the overlay's edgeCentre / nodeCentre
* helpers to land somewhere reasonable.
*/
function buildSyntheticOverflowDom(
nodes: Array<{ name: string; x: number; y: number }>,
edges: Array<{ name: string; src: string; tgt: string; midpoint: SyntheticPoint }>,
): { svg: SVGSVGElement; cleanup: () => void } {
document.body.innerHTML = '';
const root = document.createElement('div');
root.id = 'stage';
document.body.appendChild(root);
const svgNs = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(svgNs, 'svg') as SVGSVGElement;
root.appendChild(svg);
const graph = document.createElementNS(svgNs, 'g');
graph.setAttribute('class', 'graph');
svg.appendChild(graph);
// Identity CTM stubs — overlay's projectToLayer multiplies inverses
// and sums translates. With identity matrices the projection is a
// no-op and the local bbox / path coords are returned as-is.
const identityCTM = () => ({
a: 1, b: 0, c: 0, d: 1, e: 0, f: 0,
inverse() { return identityCTM(); },
multiply() { return identityCTM(); },
});
// Patch the prototype rather than per-element, so newly created
// children of frag.appendChild(...) inside render() also get the
// stubs.
(Element.prototype as unknown as { getCTM: () => unknown }).getCTM = identityCTM;
for (const n of nodes) {
const g = document.createElementNS(svgNs, 'g');
g.setAttribute('class', 'node');
g.setAttribute('data-name', n.name);
// Stub getBBox to return a node centred at (n.x, n.y).
(g as unknown as { getBBox: () => DOMRect }).getBBox = () =>
({ x: n.x - 5, y: n.y - 5, width: 10, height: 10 } as DOMRect);
graph.appendChild(g);
}
for (const e of edges) {
const g = document.createElementNS(svgNs, 'g');
g.setAttribute('class', 'edge');
g.setAttribute('data-attr-name', e.name);
g.setAttribute('data-source', e.src);
g.setAttribute('data-target', e.tgt);
const path = document.createElementNS(svgNs, 'path');
path.setAttribute('d', `M${e.src} L${e.tgt}`);
// Stub getTotalLength + getPointAtLength to land on midpoint.
(path as unknown as { getTotalLength: () => number }).getTotalLength = () => 100;
(path as unknown as {
getPointAtLength: (l: number) => DOMPoint;
}).getPointAtLength = () => ({ x: e.midpoint.x, y: e.midpoint.y } as DOMPoint);
g.appendChild(path);
graph.appendChild(g);
}
return {
svg,
cleanup: () => { document.body.innerHTML = ''; },
};
}
const noop = () => {};
describe('overflow overlay — render() actually attaches pin glyphs', () => {
beforeEach(() => {
// Silence the overlay's ``window.parent.postMessage`` calls.
vi.spyOn(window, 'postMessage').mockImplementation(noop);
});
it('renders one unitary pin per descriptor when the iframe receives cs4g:pins', () => {
const { cleanup } = buildSyntheticOverflowDom(
[
{ name: 'NODE_A', x: 100, y: 100 },
{ name: 'NODE_B', x: 200, y: 200 },
],
[{ name: 'L_AB', src: 'NODE_A', tgt: 'NODE_B', midpoint: { x: 150, y: 150 } }],
);
const overlay = loadOverlayScript();
// Wrap the IIFE body inside an explicit call so eval doesn't
// run it at module load time — we want to control timing.
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
new Function(overlay)();
// Post the cs4g:pins payload — same shape ``useOverflowIframe``
// sends from the parent window.
window.dispatchEvent(new MessageEvent('message', {
data: {
type: 'cs4g:pins',
visible: true,
pins: [
{
actionId: 'disco_L_AB',
substation: 'NODE_A',
nodeCandidates: [],
lineNames: ['L_AB'],
label: '65%',
severity: 'orange',
isSelected: false,
isRejected: false,
},
{
actionId: 'load_shedding_NODE_B',
substation: 'NODE_B',
nodeCandidates: [],
lineNames: [],
label: '79%',
severity: 'red',
isSelected: false,
isRejected: false,
},
],
},
}));
const layer = document.querySelector('g.cs4g-pin-layer');
expect(layer).not.toBeNull();
// Two unitary pins must materialise in the layer.
const pins = layer!.querySelectorAll('[data-action-id]');
expect(pins.length).toBeGreaterThanOrEqual(2);
const ids = Array.from(pins).map(p => p.getAttribute('data-action-id'));
expect(ids).toContain('disco_L_AB');
expect(ids).toContain('load_shedding_NODE_B');
cleanup();
});
it('renders the combined-pin curve + "+" badge AND keeps unitary pins visible', () => {
const { cleanup } = buildSyntheticOverflowDom(
[
{ name: 'NODE_A', x: 100, y: 100 },
{ name: 'NODE_B', x: 200, y: 200 },
{ name: 'NODE_C', x: 300, y: 300 },
],
[],
);
const overlay = loadOverlayScript();
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
new Function(overlay)();
window.dispatchEvent(new MessageEvent('message', {
data: {
type: 'cs4g:pins',
visible: true,
pins: [
{
actionId: 'a',
substation: 'NODE_A',
label: '50%', severity: 'green',
isSelected: false, isRejected: false,
},
{
actionId: 'b',
substation: 'NODE_B',
label: '60%', severity: 'orange',
isSelected: false, isRejected: false,
},
{
actionId: 'a+b',
isCombined: true,
action1Id: 'a',
action2Id: 'b',
substation: '',
label: '70%', severity: 'red',
isSelected: false, isRejected: false,
},
],
},
}));
const layer = document.querySelector('g.cs4g-pin-layer')!;
// Unitary pins must STILL be present even though a combined
// pair is drawn — this is the regression the user reported.
const ids = Array.from(layer.querySelectorAll('[data-action-id]'))
.map(p => p.getAttribute('data-action-id'));
expect(ids).toContain('a');
expect(ids).toContain('b');
expect(ids).toContain('a+b');
// Dashed connector path between the two constituents.
expect(layer.querySelector('.cs4g-overflow-combined-curve')).not.toBeNull();
// Constituents are NOT auto-dimmed. The Action Overview pin
// layer's contract is "filter pipeline owns dimming, the
// combined-pin renderer doesn't touch unitary opacity" —
// the overflow overlay now follows the same rule (was
// data-combined-constituent="1" + opacity 0.55 in 0.7.0).
expect(layer.querySelectorAll(
'[data-combined-constituent="1"]',
)).toHaveLength(0);
cleanup();
});
it('a combined-pin failure does NOT erase the unitary pins (try/catch fence)', () => {
const { cleanup } = buildSyntheticOverflowDom(
[{ name: 'NODE_A', x: 100, y: 100 }, { name: 'NODE_B', x: 200, y: 200 }],
[],
);
const overlay = loadOverlayScript();
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
new Function(overlay)();
// The combined pin references a unitary id that doesn't
// resolve to a node — this used to abort render() and leave
// the layer empty. With the try/catch fence the unitary
// pins must still come through.
window.dispatchEvent(new MessageEvent('message', {
data: {
type: 'cs4g:pins',
visible: true,
pins: [
{
actionId: 'a', substation: 'NODE_A',
label: '50%', severity: 'green',
isSelected: false, isRejected: false,
},
{
actionId: 'b', substation: 'NODE_B',
label: '60%', severity: 'orange',
isSelected: false, isRejected: false,
},
{
actionId: 'a+ghost',
isCombined: true,
action1Id: 'a',
action2Id: 'ghost-not-in-pins',
substation: '',
label: '70%', severity: 'red',
isSelected: false, isRejected: false,
},
],
},
}));
const layer = document.querySelector('g.cs4g-pin-layer')!;
const ids = Array.from(layer.querySelectorAll('[data-action-id]'))
.map(p => p.getAttribute('data-action-id'));
expect(ids).toContain('a');
expect(ids).toContain('b');
// The combined pin pointing at a missing constituent must not
// emit a glyph (the curve has no second anchor).
expect(ids).not.toContain('a+ghost');
cleanup();
});
});