Co-Study4Grid / frontend /src /utils /svgUtils.test.ts
github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
127 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, vi } from 'vitest';
import {
processSvg,
boostSvgForLargeGrid,
buildMetadataIndex,
getActionTargetLines,
getActionTargetVoltageLevels,
getIdMap,
invalidateIdMapCache,
applyOverloadedHighlights,
applyDeltaVisuals,
applyActionTargetHighlights,
applyContingencyHighlight,
buildActionOverviewPins,
buildUnsimulatedActionPins,
actionPassesOverviewFilter,
buildCombinedActionPins,
applyActionOverviewPins,
applyActionOverviewHighlights,
rescaleActionOverviewPins,
computeActionOverviewFitRect,
computeEquipmentFitRect,
PIN_SINGLE_CLICK_DELAY_MS,
} from './svgUtils';
import type { ActionDetail, NodeMeta, EdgeMeta, MetadataIndex } from '../types';
const makeEdgeMap = (...ids: string[]) => {
const map = new Map<string, EdgeMeta>();
ids.forEach(id => map.set(id, { equipmentId: id, svgId: `svg-${id}`, node1: '', node2: '' }));
return map;
};
const makeNodeMap = (...ids: string[]) => {
const map = new Map<string, NodeMeta>();
ids.forEach(id => map.set(id, { equipmentId: id, svgId: `svg-${id}`, x: 0, y: 0 }));
return map;
};
describe('processSvg', () => {
it('extracts viewBox from SVG string', () => {
const svg = '<svg viewBox="0 10 800 600"><rect/></svg>';
const result = processSvg(svg, 10);
expect(result.viewBox).toEqual({ x: 0, y: 10, w: 800, h: 600 });
});
it('returns null viewBox when not present', () => {
const svg = '<svg><rect/></svg>';
const result = processSvg(svg, 10);
expect(result.viewBox).toBeNull();
});
it('returns a live svg element (D6 element-adoption)', () => {
const svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect/></svg>';
const result = processSvg(svg, 10);
expect(typeof result.svg).not.toBe('string');
expect((result.svg as SVGSVGElement).nodeName.toLowerCase()).toBe('svg');
});
it('handles viewBox with commas', () => {
const svg = '<svg viewBox="0,0,500,400"><rect/></svg>';
const result = processSvg(svg, 10);
expect(result.viewBox).toEqual({ x: 0, y: 0, w: 500, h: 400 });
});
});
describe('boostSvgForLargeGrid', () => {
it('returns unchanged SVG for small grids (< 500 VLs)', () => {
const svg = '<svg viewBox="0 0 1000 1000"><text>hello</text></svg>';
const vb = { x: 0, y: 0, w: 1000, h: 1000 };
const result = boostSvgForLargeGrid(svg, vb, 100);
expect(result).toBe(svg);
});
it('returns unchanged SVG when viewBox is null', () => {
const svg = '<svg><text>hello</text></svg>';
const result = boostSvgForLargeGrid(svg, null, 600);
expect(result).toBe(svg);
});
it('returns unchanged SVG when ratio <= threshold', () => {
const svg = '<svg viewBox="0 0 1000 1000"><text>hello</text></svg>';
// ratio = 1000/1250 = 0.8, below threshold of 3
const vb = { x: 0, y: 0, w: 1000, h: 1000 };
const result = boostSvgForLargeGrid(svg, vb, 600);
expect(result).toBe(svg);
});
it('preserves foreignObject content and namespaces', () => {
const svg = `
<svg viewBox="0 0 10000 10000">
<g>
<circle cx="100" cy="100" r="5"/>
<foreignObject x="90" y="90" width="20" height="20">
<div xmlns="http://www.w3.org/1999/xhtml" class="label">Test Label</div>
</foreignObject>
</g>
</svg>
`.trim();
const vb = { x: 0, y: 0, w: 10000, h: 10000 };
const result = boostSvgForLargeGrid(svg, vb, 600);
expect(result).toContain('foreignObject');
expect(result).toContain('http://www.w3.org/1999/xhtml');
expect(result).toContain('Test Label');
// Ensure it doesn't get double-encoded or corrupted
expect(result).not.toContain('&lt;div');
});
});
describe('buildMetadataIndex', () => {
it('returns null for null metadata', () => {
expect(buildMetadataIndex(null)).toBeNull();
});
it('returns null for undefined metadata', () => {
expect(buildMetadataIndex(undefined)).toBeNull();
});
it('builds index from object metadata', () => {
const metadata = {
nodes: [
{ equipmentId: 'VL1', svgId: 'svg-vl1', x: 100, y: 200 },
{ equipmentId: 'VL2', svgId: 'svg-vl2', x: 300, y: 400 },
],
edges: [
{ equipmentId: 'LINE_A', svgId: 'svg-line-a', node1: 'svg-vl1', node2: 'svg-vl2' },
],
};
const index = buildMetadataIndex(metadata);
expect(index).not.toBeNull();
expect(index!.nodesByEquipmentId.get('VL1')?.svgId).toBe('svg-vl1');
expect(index!.nodesBySvgId.get('svg-vl2')?.equipmentId).toBe('VL2');
expect(index!.edgesByEquipmentId.get('LINE_A')?.svgId).toBe('svg-line-a');
expect(index!.edgesByNode.get('svg-vl1')).toHaveLength(1);
expect(index!.edgesByNode.get('svg-vl2')).toHaveLength(1);
});
it('builds index from JSON string metadata', () => {
const metadata = JSON.stringify({
nodes: [{ equipmentId: 'VL1', svgId: 'svg-1', x: 0, y: 0 }],
edges: [],
});
const index = buildMetadataIndex(metadata);
expect(index).not.toBeNull();
expect(index!.nodesByEquipmentId.get('VL1')).toBeDefined();
});
it('handles empty nodes and edges', () => {
const index = buildMetadataIndex({ nodes: [], edges: [] });
expect(index).not.toBeNull();
expect(index!.nodesByEquipmentId.size).toBe(0);
expect(index!.edgesByEquipmentId.size).toBe(0);
});
it('handles missing nodes and edges', () => {
const index = buildMetadataIndex({});
expect(index).not.toBeNull();
expect(index!.nodesByEquipmentId.size).toBe(0);
});
it('indexes edges by both nodes', () => {
const metadata = {
nodes: [],
edges: [
{ equipmentId: 'LINE_X', svgId: 'svg-x', node1: 'n1', node2: 'n2' },
{ equipmentId: 'LINE_Y', svgId: 'svg-y', node1: 'n1', node2: 'n3' },
],
};
const index = buildMetadataIndex(metadata);
expect(index!.edgesByNode.get('n1')).toHaveLength(2);
expect(index!.edgesByNode.get('n2')).toHaveLength(1);
expect(index!.edgesByNode.get('n3')).toHaveLength(1);
});
});
describe('getActionTargetLines', () => {
it('returns lines from topology when only lines are affected', () => {
const detail: ActionDetail = {
description_unitaire: 'Disconnect LINE_A',
rho_before: null,
rho_after: null,
max_rho: null,
max_rho_line: '',
is_rho_reduction: false,
action_topology: {
lines_ex_bus: { LINE_A: -1 },
lines_or_bus: { LINE_A: -1 },
gens_bus: {},
loads_bus: {},
},
};
const result = getActionTargetLines(detail, 'disco_LINE_A', makeEdgeMap('LINE_A'));
expect(result).toEqual(['LINE_A']);
});
it('returns lines when all topology values are -1 (disconnection)', () => {
const detail: ActionDetail = {
description_unitaire: 'Test',
rho_before: null,
rho_after: null,
max_rho: null,
max_rho_line: '',
is_rho_reduction: false,
action_topology: {
lines_ex_bus: { LINE_A: -1, LINE_B: -1 },
lines_or_bus: { LINE_A: -1 },
gens_bus: { GEN1: -1 },
loads_bus: { LOAD1: -1 },
},
};
const result = getActionTargetLines(detail, null, makeEdgeMap('LINE_A', 'LINE_B'));
expect(result).toContain('LINE_A');
expect(result).toContain('LINE_B');
});
it('falls back to action ID suffix (last _ segment) when no topology', () => {
const detail: ActionDetail = {
description_unitaire: 'Test',
rho_before: null,
rho_after: null,
max_rho: null,
max_rho_line: '',
is_rho_reduction: false,
};
// The function splits by '_' and takes the LAST segment
// 'disco_LINE-X' → ['disco', 'LINE-X'] → candidate = 'LINE-X'
const result = getActionTargetLines(detail, 'disco_LINE-X', makeEdgeMap('LINE-X'));
expect(result).toEqual(['LINE-X']);
});
it('returns empty when action ID suffix not in edge map', () => {
const detail: ActionDetail = {
description_unitaire: 'Test',
rho_before: null,
rho_after: null,
max_rho: null,
max_rho_line: '',
is_rho_reduction: false,
};
const result = getActionTargetLines(detail, 'disco_UNKNOWN', makeEdgeMap('LINE_A'));
expect(result).toEqual([]);
});
it('returns empty for null action detail and null action id', () => {
const result = getActionTargetLines(null, null, makeEdgeMap());
expect(result).toEqual([]);
});
it('does not return lines when gens or loads are also affected', () => {
const detail: ActionDetail = {
description_unitaire: 'Complex action',
rho_before: null,
rho_after: null,
max_rho: null,
max_rho_line: '',
is_rho_reduction: false,
action_topology: {
lines_ex_bus: { LINE_A: 1 },
lines_or_bus: { LINE_A: 1 },
gens_bus: { GEN_1: 1 },
loads_bus: {},
},
};
// With gens involved and values != -1, lines aren't pure line actions
const result = getActionTargetLines(detail, null, makeEdgeMap('LINE_A'));
expect(result).toEqual([]);
});
it('does not return lines when loads_p is present (power reduction action)', () => {
const detail: ActionDetail = {
description_unitaire: 'Load shedding via power reduction',
rho_before: null,
rho_after: null,
max_rho: null,
max_rho_line: '',
is_rho_reduction: false,
action_topology: {
lines_ex_bus: { LINE_A: 1 },
lines_or_bus: { LINE_A: 1 },
gens_bus: {},
loads_bus: {},
loads_p: { LOAD_1: 0.0 },
},
};
// loads_p present means this isn't a pure line action
const result = getActionTargetLines(detail, null, makeEdgeMap('LINE_A'));
expect(result).toEqual([]);
});
it('does not return lines when gens_p is present (curtailment power reduction)', () => {
const detail: ActionDetail = {
description_unitaire: 'Curtailment via power reduction',
rho_before: null,
rho_after: null,
max_rho: null,
max_rho_line: '',
is_rho_reduction: false,
action_topology: {
lines_ex_bus: { LINE_A: 1 },
lines_or_bus: { LINE_A: 1 },
gens_bus: {},
loads_bus: {},
gens_p: { WIND_1: 0.0 },
},
};
const result = getActionTargetLines(detail, null, makeEdgeMap('LINE_A'));
expect(result).toEqual([]);
});
it('handles combined action IDs with + separator', () => {
const detail: ActionDetail = {
description_unitaire: 'Multiple lines',
rho_before: null,
rho_after: null,
max_rho: null,
max_rho_line: '',
is_rho_reduction: false,
};
const result = getActionTargetLines(detail, 'disco_LINE_A+disco_LINE_B', makeEdgeMap('LINE_A', 'LINE_B'));
expect(result).toContain('LINE_A');
expect(result).toContain('LINE_B');
expect(result).toHaveLength(2);
});
it('extracts the line target of a combined disco+coupling action', () => {
// Regression: the combined ID contains "coupling" via the
// second sub-action, which used to trip the global
// `isCouplingAction` check and suppress ALL line-target
// extraction — the disco line lost its pink halo AND its
// action-card badge. After the fix, the coupling flag is
// evaluated per `+`-split part so the disco sub-action's
// line target is still returned.
const detail: ActionDetail = {
description_unitaire: "Ouverture de la ligne 'BEON L31P.SAO' + Ouverture COUCHP6_COUCH6COUPL DJ_OC dans le poste COUCHP6",
rho_before: null,
rho_after: null,
max_rho: null,
max_rho_line: '',
is_rho_reduction: false,
// Combined topology: merged bus changes from disco + coupling.
action_topology: {
lines_ex_bus: { 'BEON L31P.SAO': -1 },
lines_or_bus: { 'BEON L31P.SAO': -1 },
gens_bus: {},
loads_bus: {},
},
};
const result = getActionTargetLines(
detail,
'disco_BEON L31P.SAO+f344b395-9908-43c2-bca0-75c5f298465e_COUCHP6_coupling',
makeEdgeMap('BEON L31P.SAO'),
);
expect(result).toContain('BEON L31P.SAO');
});
it('returns no line targets for combined coupling+coupling actions', () => {
// Both sub-actions are VL-level — all targets flow through
// getActionTargetVoltageLevels instead. This stays empty.
const detail: ActionDetail = {
description_unitaire: 'Ouverture COUCHP6 + Ouverture C.REGP6',
rho_before: null,
rho_after: null,
max_rho: null,
max_rho_line: '',
is_rho_reduction: false,
};
const result = getActionTargetLines(
detail,
'466f2c03-90ce-401e-a458-fa177ad45abc_C.REGP6_coupling+f344b395-9908-43c2-bca0-75c5f298465e_COUCHP6_coupling',
makeEdgeMap('LINE_A'),
);
expect(result).toEqual([]);
});
it('extracts PST targets in a combined PST+coupling action', () => {
// pst_tap is always explicit in the topology, regardless of
// whether the combined action also contains a coupling.
const detail: ActionDetail = {
description_unitaire: 'PST tap + coupling',
rho_before: null,
rho_after: null,
max_rho: null,
max_rho_line: '',
is_rho_reduction: false,
action_topology: {
lines_ex_bus: {},
lines_or_bus: {},
gens_bus: {},
loads_bus: {},
pst_tap: { PST_LINE_1: 5 },
},
};
const result = getActionTargetLines(
detail,
'pst_PST_LINE_1+uuid_VL_X_coupling',
makeEdgeMap('PST_LINE_1'),
);
expect(result).toContain('PST_LINE_1');
});
it('extracts lines from pst_tap in topology', () => {
const detail: ActionDetail = {
description_unitaire: 'Change PST tap',
rho_before: null,
rho_after: null,
max_rho: null,
max_rho_line: '',
is_rho_reduction: false,
action_topology: {
pst_tap: { PST_LINE_1: 5 },
lines_ex_bus: {},
lines_or_bus: {},
gens_bus: {},
loads_bus: {},
},
};
const result = getActionTargetLines(detail, null, makeEdgeMap('PST_LINE_1'));
expect(result).toEqual(['PST_LINE_1']);
});
it('strips _inc/_dec suffixes from action ID parts', () => {
const detail: ActionDetail = {
description_unitaire: 'PST action with suffix',
rho_before: null,
rho_after: null,
max_rho: null,
max_rho_line: '',
is_rho_reduction: false,
};
// pst_tap_.ARKA TD 661_inc2 -> stripped to pst_tap_.ARKA TD 661
// then parsed to .ARKA TD 661
const result = getActionTargetLines(detail, 'pst_tap_LINE_PST_inc2', makeEdgeMap('LINE_PST'));
expect(result).toEqual(['LINE_PST']);
});
it('handles combined PST actions with suffixes', () => {
const detail: ActionDetail = {
description_unitaire: 'Combined PST and Line',
rho_before: null,
rho_after: null,
max_rho: null,
max_rho_line: '',
is_rho_reduction: false,
};
const result = getActionTargetLines(
detail,
'pst_tap_PST_A_inc1+disco_LINE_B',
makeEdgeMap('PST_A', 'LINE_B')
);
expect(result).toContain('PST_A');
expect(result).toContain('LINE_B');
expect(result).toHaveLength(2);
});
it('suppresses topology lines for coupling actions but keeps pst_tap', () => {
const detail: ActionDetail = {
description_unitaire: 'Coupling with side effects',
rho_before: null,
rho_after: null,
max_rho: null,
max_rho_line: '',
is_rho_reduction: false,
action_topology: {
lines_ex_bus: { LINE_SIDE_EFFECT: -1 },
pst_tap: { PST_LINE: 5 },
lines_or_bus: {},
gens_bus: {},
loads_bus: {},
},
};
const result = getActionTargetLines(detail, 'MQIS P7_coupling', makeEdgeMap('LINE_SIDE_EFFECT', 'PST_LINE'));
// Should NOT contain LINE_SIDE_EFFECT (it's a side effect of coupling)
expect(result).not.toContain('LINE_SIDE_EFFECT');
// Should STILL contain PST_LINE
expect(result).toContain('PST_LINE');
expect(result).toHaveLength(1);
});
});
describe('getActionTargetVoltageLevels', () => {
it('extracts the VL from a redispatch_details entry (gen highlight)', () => {
const detail: ActionDetail = {
description_unitaire: 'No description available',
rho_before: null, rho_after: null, max_rho: null, max_rho_line: '',
is_rho_reduction: false,
redispatch_details: [
{ gen_name: 'THERM_1', voltage_level_id: 'VL_GEN', delta_mw: 10, target_mw: 40, direction: 'up' },
],
};
const result = getActionTargetVoltageLevels(detail, 'redispatch_THERM_1', makeNodeMap('VL_GEN'));
expect(result).toEqual(['VL_GEN']);
});
it('extracts the VL from load_shedding / curtailment detail arrays too', () => {
const ls: ActionDetail = {
description_unitaire: 'No description available',
rho_before: null, rho_after: null, max_rho: null, max_rho_line: '',
is_rho_reduction: false,
load_shedding_details: [{ load_name: 'L1', voltage_level_id: 'VL_LS', shedded_mw: 5 }],
};
expect(getActionTargetVoltageLevels(ls, 'load_shedding_L1', makeNodeMap('VL_LS'))).toEqual(['VL_LS']);
const rc: ActionDetail = {
description_unitaire: 'No description available',
rho_before: null, rho_after: null, max_rho: null, max_rho_line: '',
is_rho_reduction: false,
curtailment_details: [{ gen_name: 'W1', voltage_level_id: 'VL_RC', curtailed_mw: 8 }],
};
expect(getActionTargetVoltageLevels(rc, 'curtail_W1', makeNodeMap('VL_RC'))).toEqual(['VL_RC']);
});
it('extracts VL from quoted string in description', () => {
const detail: ActionDetail = {
description_unitaire: "Ouverture couplage dans le poste 'SUBSTATION_A'",
rho_before: null,
rho_after: null,
max_rho: null,
max_rho_line: '',
is_rho_reduction: false,
};
const result = getActionTargetVoltageLevels(detail, null, makeNodeMap('SUBSTATION_A'));
expect(result).toEqual(['SUBSTATION_A']);
});
it('tries last quoted string first', () => {
const detail: ActionDetail = {
description_unitaire: "Action on 'IRRELEVANT' at poste 'VL_TARGET'",
rho_before: null,
rho_after: null,
max_rho: null,
max_rho_line: '',
is_rho_reduction: false,
};
const result = getActionTargetVoltageLevels(detail, null, makeNodeMap('VL_TARGET'));
expect(result).toEqual(['VL_TARGET']);
});
it('falls back to action ID suffix', () => {
const detail: ActionDetail = {
description_unitaire: 'No description available',
rho_before: null,
rho_after: null,
max_rho: null,
max_rho_line: '',
is_rho_reduction: false,
};
const result = getActionTargetVoltageLevels(detail, 'open_coupling_VL1', makeNodeMap('VL1'));
expect(result).toEqual(['VL1']);
});
it('returns empty array when no match found', () => {
const detail: ActionDetail = {
description_unitaire: 'Some generic action',
rho_before: null,
rho_after: null,
max_rho: null,
max_rho_line: '',
is_rho_reduction: false,
};
const result = getActionTargetVoltageLevels(detail, 'action_UNKNOWN', makeNodeMap('VL1'));
expect(result).toEqual([]);
});
it('returns empty array for null detail', () => {
const result = getActionTargetVoltageLevels(null, null, makeNodeMap('VL1'));
expect(result).toEqual([]);
});
it('extracts multiple VLs from combined ID', () => {
const detail: ActionDetail = {
description_unitaire: 'No description available',
rho_before: null,
rho_after: null,
max_rho: null,
max_rho_line: '',
is_rho_reduction: false,
};
const result = getActionTargetVoltageLevels(detail, 'open_coupling_VL1+open_coupling_VL2', makeNodeMap('VL1', 'VL2'));
expect(result).toContain('VL1');
expect(result).toContain('VL2');
expect(result).toHaveLength(2);
});
it('extracts multiple VLs from description using quoted strings and "poste" keyword', () => {
const detail: ActionDetail = {
description_unitaire: "Action entre le poste 'VL1' and 'VL2', plus a manual action au poste VL3",
rho_before: null,
rho_after: null,
max_rho: null,
max_rho_line: '',
is_rho_reduction: false,
};
const result = getActionTargetVoltageLevels(detail, null, makeNodeMap('VL1', 'VL2', 'VL3'));
expect(result).toContain('VL1');
expect(result).toContain('VL2');
expect(result).toContain('VL3');
expect(result).toHaveLength(3);
});
it('skips action ID fallback for line reconnection actions', () => {
const detail: ActionDetail = {
description_unitaire: 'Reconnect line',
rho_before: null,
rho_after: null,
max_rho: null,
max_rho_line: '',
is_rho_reduction: false,
action_topology: {
lines_ex_bus: { LINE_A: 1 },
lines_or_bus: { LINE_A: 2 },
gens_bus: {},
loads_bus: {},
},
};
// Even though action ID suffix matches a VL, should return empty array
// because it's a line reconnection (lines with bus >= 0, no gen/load)
const result = getActionTargetVoltageLevels(detail, 'reco_VL1', makeNodeMap('VL1'));
expect(result).toEqual([]);
});
it('does not skip action ID fallback when loads_p is present (not a reconnection)', () => {
const detail: ActionDetail = {
description_unitaire: 'Power reduction on load',
rho_before: null,
rho_after: null,
max_rho: null,
max_rho_line: '',
is_rho_reduction: false,
action_topology: {
lines_ex_bus: { LINE_A: 1 },
lines_or_bus: { LINE_A: 2 },
gens_bus: {},
loads_bus: {},
loads_p: { LOAD_1: 0.0 },
},
};
// Even though lines have bus >= 0, loads_p presence means it's NOT a pure reconnection
const result = getActionTargetVoltageLevels(detail, 'action_VL1', makeNodeMap('VL1'));
expect(result).toEqual(['VL1']);
});
it('strips _inc/_dec suffixes in fallback ID parsing', () => {
const detail: ActionDetail = {
description_unitaire: 'No description available',
rho_before: null,
rho_after: null,
max_rho: null,
max_rho_line: '',
is_rho_reduction: false,
};
const result = getActionTargetVoltageLevels(detail, 'open_coupling_VL1_inc2', makeNodeMap('VL1'));
expect(result).toEqual(['VL1']);
});
it('extracts VL with spaces from description without quotes', () => {
const detail: ActionDetail = {
description_unitaire: "Ouverture MQIS P7_MQIS 7COUPL DJ_OC dans le poste MQIS P7",
rho_before: null,
rho_after: null,
max_rho: null,
max_rho_line: '',
is_rho_reduction: false,
};
const result = getActionTargetVoltageLevels(detail, null, makeNodeMap('MQIS P7'));
expect(result).toEqual(['MQIS P7']);
});
it('extracts VL from mid-ID segment with spaces', () => {
const detail: ActionDetail = {
description_unitaire: 'No description available',
rho_before: null,
rho_after: null,
max_rho: null,
max_rho_line: '',
is_rho_reduction: false,
};
const actionId = 'de829050-177c-4244-ba94-61b22d2684a4_MQIS P7_coupling';
const result = getActionTargetVoltageLevels(detail, actionId, makeNodeMap('MQIS P7'));
expect(result).toEqual(['MQIS P7']);
});
it('extracts VL using prefix matching (handles "MICQ P7 is open")', () => {
const detail: ActionDetail = {
description_unitaire: "Ouverture dans le poste MICQ P7 is open",
rho_before: null,
rho_after: null,
max_rho: null,
max_rho_line: '',
is_rho_reduction: false,
};
const result = getActionTargetVoltageLevels(detail, null, makeNodeMap('MICQ P7'));
expect(result).toEqual(['MICQ P7']);
});
it('recognizes "coupl" substring (e.g. in COUCH6COUPL) for coupling detection', () => {
const detail: ActionDetail = {
description_unitaire: "Ouverture OC 'COUCH6COUPL DJ_OC' dans le poste 'COUCHP6'",
rho_before: null,
rho_after: null,
max_rho: null,
max_rho_line: '',
is_rho_reduction: false,
action_topology: {
lines_ex_bus: { LINE1: -1 },
lines_or_bus: {},
gens_bus: {},
loads_bus: {},
}
} as unknown as ActionDetail;
const result = getActionTargetLines(detail, 'f344..._COUCHP6', makeEdgeMap('LINE1'));
// Should suppress LINE1 because it's a coupling action (detected "COUPL")
expect(result).not.toContain('LINE1');
});
it('recognizes French "noeud" keyword for coupling detection', () => {
const detail: ActionDetail = {
description_unitaire: "Reconfiguration au noeud",
rho_before: null,
rho_after: null,
max_rho: null,
max_rho_line: '',
is_rho_reduction: false,
action_topology: {
lines_ex_bus: { LINE1: -1 },
lines_or_bus: {},
gens_bus: {},
loads_bus: {},
}
} as unknown as ActionDetail;
const result = getActionTargetLines(detail, 'some_uuid', makeEdgeMap('LINE1'));
expect(result).not.toContain('LINE1');
});
});
describe('getIdMap', () => {
const makeContainer = (html: string) => {
const div = document.createElement('div');
div.innerHTML = html;
return div;
};
it('builds a map of id → element from container children', () => {
const container = makeContainer('<svg><g id="node1"></g><g id="node2"></g></svg>');
const map = getIdMap(container);
expect(map.size).toBeGreaterThanOrEqual(2);
expect(map.get('node1')).toBeDefined();
expect(map.get('node2')).toBeDefined();
});
it('returns the same cached map on repeated calls', () => {
const container = makeContainer('<svg><g id="a"></g></svg>');
const map1 = getIdMap(container);
const map2 = getIdMap(container);
expect(map1).toBe(map2);
});
it('rebuilds the map when the SVG element changes', () => {
const container = makeContainer('<svg><g id="a"></g></svg>');
const map1 = getIdMap(container);
expect(map1.get('a')).toBeDefined();
// Replace the SVG entirely
container.innerHTML = '<svg><g id="b"></g></svg>';
const map2 = getIdMap(container);
expect(map2).not.toBe(map1);
expect(map2.get('b')).toBeDefined();
expect(map2.get('a')).toBeUndefined();
});
it('rebuilds after invalidateIdMapCache is called', () => {
const container = makeContainer('<svg><g id="x"></g></svg>');
const map1 = getIdMap(container);
invalidateIdMapCache(container);
const map2 = getIdMap(container);
// Different Map instance even though content is the same
expect(map2).not.toBe(map1);
});
});
describe('applyOverloadedHighlights', () => {
const makeMetaIndex = (): MetadataIndex => ({
nodesByEquipmentId: new Map(),
nodesBySvgId: new Map(),
edgesByEquipmentId: new Map([
['LINE_A', { equipmentId: 'LINE_A', svgId: 'svg-line-a', node1: 'n1', node2: 'n2' }],
['LINE_B', { equipmentId: 'LINE_B', svgId: 'svg-line-b', node1: 'n2', node2: 'n3' }],
]),
edgesByNode: new Map(),
});
it('adds nad-overloaded clone for matching edges', () => {
const container = document.createElement('div');
container.innerHTML = '<svg><g id="svg-line-a"><path/></g><g id="svg-line-b"><path/></g></svg>';
const metaIndex = makeMetaIndex();
applyOverloadedHighlights(container, metaIndex, ['LINE_A']);
// Clone with nad-overloaded should exist
const clones = container.querySelectorAll('.nad-highlight-clone.nad-overloaded');
expect(clones.length).toBe(1);
// Original should NOT have the class
const elB = container.querySelector('#svg-line-b');
expect(elB?.classList.contains('nad-overloaded')).toBe(false);
});
it('clears previous overloaded highlight clones before applying new ones', () => {
const container = document.createElement('div');
container.innerHTML = '<svg><g id="svg-line-a"><path/></g><g id="svg-line-b"><path/></g></svg>';
const metaIndex = makeMetaIndex();
// First apply on LINE_A
applyOverloadedHighlights(container, metaIndex, ['LINE_A']);
expect(container.querySelectorAll('.nad-highlight-clone.nad-overloaded').length).toBe(1);
// Now apply on LINE_B — should clear LINE_A clone
applyOverloadedHighlights(container, metaIndex, ['LINE_B']);
const clones = container.querySelectorAll('.nad-highlight-clone.nad-overloaded');
expect(clones.length).toBe(1);
});
it('does nothing with empty overloaded lines array', () => {
const container = document.createElement('div');
container.innerHTML = '<svg><g id="svg-line-a"><path/></g></svg>';
const metaIndex = makeMetaIndex();
applyOverloadedHighlights(container, metaIndex, []);
// No clones should be added
expect(container.querySelectorAll('.nad-highlight-clone.nad-overloaded').length).toBe(0);
});
});
describe('applyDeltaVisuals', () => {
const makeMetaIndex = (): MetadataIndex => ({
nodesByEquipmentId: new Map(),
nodesBySvgId: new Map(),
edgesByEquipmentId: new Map([
['LINE_A', {
equipmentId: 'LINE_A', svgId: 'svg-line-a', node1: 'n1', node2: 'n2',
edgeInfo1: { svgId: 'info1-a' },
edgeInfo2: { svgId: 'info2-a' },
} as EdgeMeta],
]),
edgesByNode: new Map(),
});
it('applies delta classes and text in delta mode', () => {
const container = document.createElement('div');
container.innerHTML = `<svg>
<g id="svg-line-a"><path/></g>
<g id="info1-a"><text>123.4</text></g>
<g id="info2-a"><text>56.7</text></g>
</svg>`;
const metaIndex = makeMetaIndex();
const diagram = {
flow_deltas: { LINE_A: { delta: 5.2, category: 'positive' } },
};
applyDeltaVisuals(container, diagram, metaIndex, true);
expect(container.querySelector('#svg-line-a')?.classList.contains('nad-delta-positive')).toBe(true);
expect(container.querySelector('#info1-a text')?.textContent).toBe('\u0394 +5.2');
expect(container.querySelector('#info1-a text')?.getAttribute('data-original-text')).toBe('123.4');
});
it('restores original text when switching out of delta mode', () => {
const container = document.createElement('div');
container.innerHTML = `<svg>
<g id="svg-line-a"><path/></g>
<g id="info1-a"><text data-original-text="123.4">Δ +5.2</text></g>
<g id="info2-a"><text data-original-text="56.7">Δ +5.2</text></g>
</svg>`;
const metaIndex = makeMetaIndex();
applyDeltaVisuals(container, { flow_deltas: {} }, metaIndex, false);
expect(container.querySelector('#info1-a text')?.textContent).toBe('123.4');
expect(container.querySelector('#info1-a text')?.hasAttribute('data-original-text')).toBe(false);
});
it('does nothing when diagram is null', () => {
const container = document.createElement('div');
container.innerHTML = '<svg><g id="svg-line-a"><path/></g></svg>';
const metaIndex = makeMetaIndex();
// Should not throw
applyDeltaVisuals(container, null, metaIndex, true);
});
it('applies negative delta class correctly', () => {
const container = document.createElement('div');
container.innerHTML = `<svg>
<g id="svg-line-a"><path/></g>
<g id="info1-a"><text>10</text></g>
<g id="info2-a"><text>20</text></g>
</svg>`;
const metaIndex = makeMetaIndex();
const diagram = {
flow_deltas: { LINE_A: { delta: -3.7, category: 'negative' } },
};
applyDeltaVisuals(container, diagram, metaIndex, true);
expect(container.querySelector('#svg-line-a')?.classList.contains('nad-delta-negative')).toBe(true);
expect(container.querySelector('#info1-a text')?.textContent).toBe('\u0394 -3.7');
});
});
describe('Highlight Layering', () => {
it('inserts the contingency clone as an immediate previous sibling (better stability)', () => {
const container = document.createElement('div');
container.innerHTML = '<svg><g id="svg-line-a"></g><g id="svg-line-b"></g></svg>';
const metaIndex = {
edgesByEquipmentId: new Map([
['LINE_A', { equipmentId: 'LINE_A', svgId: 'svg-line-a' } as EdgeMeta],
['LINE_B', { equipmentId: 'LINE_B', svgId: 'svg-line-b' } as EdgeMeta],
]),
nodesByEquipmentId: new Map(),
nodesBySvgId: new Map(),
edgesByNode: new Map(),
} as unknown as MetadataIndex;
// Apply contingency highlight to LINE_B
applyContingencyHighlight(container, metaIndex, 'LINE_B');
const bgLayer = container.querySelector('#nad-background-layer');
expect(bgLayer).not.toBeNull();
const clone = bgLayer!.querySelector('.nad-contingency-highlight') as SVGGraphicsElement;
expect(clone).not.toBeNull();
expect(clone.classList.contains('nad-highlight-clone')).toBe(true);
expect(clone.style.display).toBe('block');
expect(clone.style.visibility).toBe('visible');
expect(clone.getAttribute('transform')).toMatch(/matrix/);
});
it('verifies that the background layer is the first child of the SVG (z-order)', () => {
const container = document.createElement('div');
container.innerHTML = `
<svg><g id="svg-a"></g><g id="grid-layer"></g></svg>
`;
const metaIndex = {
edgesByEquipmentId: new Map([['LINE_A', { svgId: 'svg-a' } as EdgeMeta]]),
nodesByEquipmentId: new Map(),
nodesBySvgId: new Map(),
edgesByNode: new Map()
} as MetadataIndex;
// Trigger background layer creation by applying a highlight to a valid element
applyContingencyHighlight(container, metaIndex, 'LINE_A');
const svg = container.querySelector('svg')!;
expect(svg.firstElementChild).not.toBeNull();
expect(svg.firstElementChild!.id).toBe('nad-background-layer');
});
it('exhaustive cleanup: removes existing contingency highlights before adding new ones', () => {
const container = document.createElement('div');
// Setup multiple clones and one original with the class
container.innerHTML = `
<svg>
<g id="nad-background-layer">
<path class="nad-contingency-highlight nad-highlight-clone"></path>
<path class="nad-contingency-highlight nad-highlight-clone"></path>
</g>
<path id="original" class="nad-contingency-highlight"></path>
</svg>
`;
const metaIndex = {
edgesByEquipmentId: new Map([['NEW_LINE', { equipmentId: 'NEW', svgId: 'original_new' } as EdgeMeta]]),
nodesByEquipmentId: new Map(),
nodesBySvgId: new Map(),
edgesByNode: new Map()
} as MetadataIndex;
// Mock getIdMap to return no element for NEW_LINE to avoid creating new ones during cleanup check
applyContingencyHighlight(container, metaIndex, 'NON_EXISTENT');
expect(container.querySelectorAll('.nad-highlight-clone').length).toBe(0);
expect(container.querySelector('#original')?.classList.contains('nad-contingency-highlight')).toBe(false);
});
describe('applyActionTargetHighlights', () => {
it('adds nad-action-target-original class to original elements and creates clones in background layer', () => {
const container = document.createElement('div');
container.innerHTML = `
<svg>
<g id="nad-background-layer"></g>
<path id="svg-L1" class="line"></path>
<circle id="svg-N1" class="node"></circle>
</svg>
`;
const metaIndex = {
edgesByEquipmentId: new Map([['L1', { equipmentId: 'L1', svgId: 'svg-L1' } as EdgeMeta]]),
nodesByEquipmentId: new Map([['N1', { equipmentId: 'N1', svgId: 'svg-N1' } as NodeMeta]]),
nodesBySvgId: new Map(),
edgesByNode: new Map()
} as MetadataIndex;
const actionDetail = {
description_unitaire: "Ouvrir 'L1' et 'N1'",
action_topology: {
lines_ex_bus: { 'L1': -1 },
gens_bus: { 'N1': -1 }
}
} as unknown as ActionDetail;
applyActionTargetHighlights(container, metaIndex, actionDetail, 'act-N1');
const originalLine = container.querySelector('#svg-L1');
const originalNode = container.querySelector('#svg-N1');
expect(originalLine?.classList.contains('nad-action-target-original')).toBe(true);
expect(originalNode?.classList.contains('nad-action-target-original')).toBe(true);
const clones = container.querySelectorAll('.nad-highlight-clone.nad-action-target');
expect(clones.length).toBe(2);
expect((clones[0].parentNode as Element)?.id).toBe('nad-background-layer');
});
it('cleans up existing highlights and original classes before applying new ones', () => {
const container = document.createElement('div');
container.innerHTML = `
<svg>
<g id="nad-background-layer">
<path class="nad-action-target nad-highlight-clone"></path>
</g>
<path id="svg-L1" class="nad-action-target-original"></path>
</svg>
`;
const metaIndex = {
edgesByEquipmentId: new Map(),
nodesByEquipmentId: new Map(),
nodesBySvgId: new Map(),
edgesByNode: new Map()
} as MetadataIndex;
applyActionTargetHighlights(container, metaIndex, null, null);
expect(container.querySelectorAll('.nad-highlight-clone').length).toBe(0);
expect(container.querySelector('#svg-L1')?.classList.contains('nad-action-target-original')).toBe(false);
});
// REGRESSION (Remedial Action tab): applyHighlightsForTab calls
// applyOverloadedHighlights FIRST (planting .nad-overloaded clones
// in the background layer) and then applyActionTargetHighlights
// right after. The latter used to blanket-remove every
// `.nad-highlight-clone`, which wiped the freshly planted
// overload clones and left the main Network Action tab with no
// orange halos for persistent / new overloads. It must now only
// remove its own `.nad-action-target` clones.
it('preserves existing .nad-overloaded clones when re-applying action target highlights', () => {
const container = document.createElement('div');
container.innerHTML = `
<svg>
<g id="nad-background-layer">
<path class="nad-overloaded nad-highlight-clone" data-test="overload-A"></path>
<path class="nad-action-target nad-highlight-clone" data-test="action-stale"></path>
</g>
<path id="svg-L1" class="line"></path>
</svg>
`;
const metaIndex = {
edgesByEquipmentId: new Map([['L1', { equipmentId: 'L1', svgId: 'svg-L1' } as EdgeMeta]]),
nodesByEquipmentId: new Map(),
nodesBySvgId: new Map(),
edgesByNode: new Map(),
} as MetadataIndex;
const actionDetail = {
description_unitaire: "Ouvrir 'L1'",
action_topology: { lines_ex_bus: { L1: -1 } },
} as unknown as ActionDetail;
applyActionTargetHighlights(container, metaIndex, actionDetail, 'act-L1');
// Overload clone must STILL be in the DOM (this is the
// regression guard — before the fix it was removed).
expect(
container.querySelector('[data-test="overload-A"]'),
).toBeTruthy();
expect(
container.querySelectorAll('.nad-highlight-clone.nad-overloaded').length,
).toBe(1);
// Stale action-target clone was removed, and a new one was
// planted for L1.
expect(
container.querySelector('[data-test="action-stale"]'),
).toBeNull();
const newActionClones = container.querySelectorAll(
'.nad-highlight-clone.nad-action-target',
);
expect(newActionClones.length).toBe(1);
});
// When called with a null actionDetail (i.e. "deselect the
// action") we still want to scrub out any stale action-target
// clones, but the overload clones must remain untouched.
it('preserves overload clones when called with null actionDetail', () => {
const container = document.createElement('div');
container.innerHTML = `
<svg>
<g id="nad-background-layer">
<path class="nad-overloaded nad-highlight-clone"></path>
<path class="nad-action-target nad-highlight-clone"></path>
</g>
</svg>
`;
const metaIndex = {
edgesByEquipmentId: new Map(),
nodesByEquipmentId: new Map(),
nodesBySvgId: new Map(),
edgesByNode: new Map(),
} as MetadataIndex;
applyActionTargetHighlights(container, metaIndex, null, null);
// Action-target clone gone, overload clone preserved.
expect(
container.querySelectorAll('.nad-highlight-clone.nad-action-target').length,
).toBe(0);
expect(
container.querySelectorAll('.nad-highlight-clone.nad-overloaded').length,
).toBe(1);
});
});
// REGRESSION (Impacts mode on N-1 / Remedial Action tabs):
// applyDeltaVisuals tags the ORIGINAL svg elements with
// `.nad-delta-positive / .nad-delta-negative / .nad-delta-grey`.
// The clone-based highlight functions then call cloneNode(true)
// on those tagged originals; without scrubbing the clone, the
// clone inherits the delta class. Because the .nad-delta-* CSS
// rules are declared LATER in App.css than the .nad-overloaded /
// .nad-action-target / .nad-contingency-highlight rules, they win
// the cascade and turn the halo into a 3px delta-colored line —
// visually making the highlight disappear in Impacts mode.
//
// Each highlight function must therefore strip nad-delta-* classes
// from its clones immediately after cloneNode.
describe('Highlight clones strip nad-delta-* classes (Impacts mode regression)', () => {
const buildNAD = () => {
const container = document.createElement('div');
container.innerHTML = `
<svg>
<g id="nad-background-layer"></g>
<path id="svg-line-a" class="nad-delta-positive"></path>
<path id="svg-line-c" class="nad-delta-grey"></path>
<path id="svg-line-action" class="nad-delta-negative"></path>
</svg>
`;
return container;
};
it('applyOverloadedHighlights clones are free of nad-delta-* classes', () => {
const container = buildNAD();
const metaIndex = {
edgesByEquipmentId: new Map([
['LINE_A', { equipmentId: 'LINE_A', svgId: 'svg-line-a', node1: 'n1', node2: 'n2' }],
['LINE_C', { equipmentId: 'LINE_C', svgId: 'svg-line-c', node1: 'n3', node2: 'n4' }],
]),
nodesByEquipmentId: new Map(),
nodesBySvgId: new Map(),
edgesByNode: new Map(),
} as MetadataIndex;
applyOverloadedHighlights(container, metaIndex, ['LINE_A', 'LINE_C']);
const clones = container.querySelectorAll('.nad-highlight-clone.nad-overloaded');
expect(clones.length).toBe(2);
clones.forEach(c => {
expect(c.classList.contains('nad-delta-positive')).toBe(false);
expect(c.classList.contains('nad-delta-negative')).toBe(false);
expect(c.classList.contains('nad-delta-grey')).toBe(false);
});
});
it('applyContingencyHighlight clone is free of nad-delta-* classes', () => {
const container = buildNAD();
const metaIndex = {
edgesByEquipmentId: new Map([
['LINE_A', { equipmentId: 'LINE_A', svgId: 'svg-line-a', node1: 'n1', node2: 'n2' }],
]),
nodesByEquipmentId: new Map(),
nodesBySvgId: new Map(),
edgesByNode: new Map(),
} as MetadataIndex;
applyContingencyHighlight(container, metaIndex, 'LINE_A');
const clone = container.querySelector('.nad-highlight-clone.nad-contingency-highlight');
expect(clone).toBeTruthy();
expect(clone!.classList.contains('nad-delta-positive')).toBe(false);
expect(clone!.classList.contains('nad-delta-negative')).toBe(false);
expect(clone!.classList.contains('nad-delta-grey')).toBe(false);
});
it('applyActionTargetHighlights clones are free of nad-delta-* classes', () => {
const container = buildNAD();
const metaIndex = {
edgesByEquipmentId: new Map([
['LINE_TARGET', { equipmentId: 'LINE_TARGET', svgId: 'svg-line-action' } as EdgeMeta],
]),
nodesByEquipmentId: new Map(),
nodesBySvgId: new Map(),
edgesByNode: new Map(),
} as MetadataIndex;
const actionDetail = {
description_unitaire: "Ouvrir 'LINE_TARGET'",
action_topology: { lines_ex_bus: { LINE_TARGET: -1 } },
} as unknown as ActionDetail;
applyActionTargetHighlights(container, metaIndex, actionDetail, 'act-1');
const clones = container.querySelectorAll('.nad-highlight-clone.nad-action-target');
expect(clones.length).toBeGreaterThanOrEqual(1);
clones.forEach(c => {
expect(c.classList.contains('nad-delta-positive')).toBe(false);
expect(c.classList.contains('nad-delta-negative')).toBe(false);
expect(c.classList.contains('nad-delta-grey')).toBe(false);
});
});
// CSS sanity check: the .nad-delta-* declarations come after
// .nad-contingency-highlight / .nad-overloaded / .nad-action-target
// in App.css. If anyone reorders them so .nad-delta-* moves
// BEFORE the highlight rules, the cascade flips and the strip
// dance above stops being necessary — but right now this is
// the ordering we depend on.
it('App.css declares .nad-delta-* AFTER the highlight rules', () => {
// Lazy require to keep this test independent of the rest.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const fs = require('fs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const path = require('path');
const css = fs.readFileSync(path.resolve(__dirname, '../App.css'), 'utf-8') as string;
const overloadIdx = css.indexOf('.nad-overloaded path');
const contingencyIdx = css.indexOf('.nad-contingency-highlight path');
const actionTargetIdx = css.indexOf('.nad-action-target path');
const deltaIdx = css.indexOf('.nad-delta-positive path');
expect(overloadIdx).toBeGreaterThan(-1);
expect(contingencyIdx).toBeGreaterThan(-1);
expect(actionTargetIdx).toBeGreaterThan(-1);
expect(deltaIdx).toBeGreaterThan(Math.max(overloadIdx, contingencyIdx, actionTargetIdx));
});
});
// REGRESSION (Remedial Action tab post–action-variant-diagram-patch):
// The three NAD halo primitives append clones to
// `#nad-background-layer` in call-order; the last-appended clone
// draws on top (SVG paints in document order). The product
// spec is "action halo > overload halo > contingency halo"
// (bottom-to-top), so the call order must be contingency →
// overload → action. Before the fix, useDiagramHighlights invoked
// them as overload → action → contingency on the Remedial Action
// tab, which put the yellow contingency halo on top of the pink
// action halo — reversed.
describe('Halo layering order (contingency below overload below action)', () => {
const buildNAD = () => {
const container = document.createElement('div');
container.innerHTML = `
<svg>
<g id="svg-cont" data-role="contingency"></g>
<g id="svg-ovl" data-role="overload"></g>
<g id="svg-act" data-role="action"></g>
</svg>
`;
return container;
};
const metaIndex = {
edgesByEquipmentId: new Map([
['CONT_LINE', { equipmentId: 'CONT_LINE', svgId: 'svg-cont' } as EdgeMeta],
['OVL_LINE', { equipmentId: 'OVL_LINE', svgId: 'svg-ovl' } as EdgeMeta],
['ACT_LINE', { equipmentId: 'ACT_LINE', svgId: 'svg-act' } as EdgeMeta],
]),
nodesByEquipmentId: new Map(),
nodesBySvgId: new Map(),
edgesByNode: new Map(),
} as MetadataIndex;
it('Remedial Action tab call-order (contingency → overload → action) stacks halos bottom → top', () => {
const container = buildNAD();
const actionDetail = {
description_unitaire: "Ouvrir 'ACT_LINE'",
action_topology: { lines_ex_bus: { ACT_LINE: -1 } },
} as unknown as ActionDetail;
// Exact order of useDiagramHighlights.applyHighlightsForTab('action'):
applyContingencyHighlight(container, metaIndex, 'CONT_LINE');
applyOverloadedHighlights(container, metaIndex, ['OVL_LINE']);
applyActionTargetHighlights(container, metaIndex, actionDetail, 'act-1');
const bgLayer = container.querySelector('#nad-background-layer');
expect(bgLayer).not.toBeNull();
const children = Array.from(bgLayer!.children) as Element[];
// First appended is drawn at the bottom; last appended is on top.
const classes = children.map(c => {
if (c.classList.contains('nad-contingency-highlight')) return 'contingency';
if (c.classList.contains('nad-overloaded')) return 'overload';
if (c.classList.contains('nad-action-target')) return 'action';
return 'other';
});
const contingencyIdx = classes.indexOf('contingency');
const overloadIdx = classes.indexOf('overload');
const actionIdx = classes.indexOf('action');
expect(contingencyIdx).toBeGreaterThanOrEqual(0);
expect(overloadIdx).toBeGreaterThanOrEqual(0);
expect(actionIdx).toBeGreaterThanOrEqual(0);
// Bottom → top: contingency, then overload, then action.
expect(contingencyIdx).toBeLessThan(overloadIdx);
expect(overloadIdx).toBeLessThan(actionIdx);
});
it('N-1 tab call-order (contingency → overload) stacks contingency below overload', () => {
const container = buildNAD();
// Exact order of useDiagramHighlights.applyHighlightsForTab('contingency'):
applyContingencyHighlight(container, metaIndex, 'CONT_LINE');
applyOverloadedHighlights(container, metaIndex, ['OVL_LINE']);
const bgLayer = container.querySelector('#nad-background-layer');
expect(bgLayer).not.toBeNull();
const children = Array.from(bgLayer!.children) as Element[];
const contingencyIdx = children.findIndex(c =>
c.classList.contains('nad-contingency-highlight'),
);
const overloadIdx = children.findIndex(c =>
c.classList.contains('nad-overloaded'),
);
expect(contingencyIdx).toBeGreaterThanOrEqual(0);
expect(overloadIdx).toBeGreaterThanOrEqual(0);
// Bottom → top: contingency, then overload.
expect(contingencyIdx).toBeLessThan(overloadIdx);
});
});
});
// ============================================================
// Action-overview pins + fit-rect helpers
// ============================================================
const makeOverviewMetaIndex = (): MetadataIndex => {
// Simple 4-node grid laid out as:
// N1 (0,0) ──LINE_A── N2 (100,0)
// │ │
// LINE_C LINE_B
// │ │
// N3 (0,100) ─LINE_D─ N4 (100,100)
//
// Plus an isolated voltage-level "VL_FAR" at (500, 500).
const nodes: NodeMeta[] = [
{ equipmentId: 'VL_N1', svgId: 'svg-n1', x: 0, y: 0 },
{ equipmentId: 'VL_N2', svgId: 'svg-n2', x: 100, y: 0 },
{ equipmentId: 'VL_N3', svgId: 'svg-n3', x: 0, y: 100 },
{ equipmentId: 'VL_N4', svgId: 'svg-n4', x: 100, y: 100 },
{ equipmentId: 'VL_FAR', svgId: 'svg-far', x: 500, y: 500 },
];
const edges: EdgeMeta[] = [
{ equipmentId: 'LINE_A', svgId: 'svg-line-a', node1: 'svg-n1', node2: 'svg-n2' },
{ equipmentId: 'LINE_B', svgId: 'svg-line-b', node1: 'svg-n2', node2: 'svg-n4' },
{ equipmentId: 'LINE_C', svgId: 'svg-line-c', node1: 'svg-n1', node2: 'svg-n3' },
{ equipmentId: 'LINE_D', svgId: 'svg-line-d', node1: 'svg-n3', node2: 'svg-n4' },
];
const nodesByEquipmentId = new Map(nodes.map(n => [n.equipmentId, n] as const));
const nodesBySvgId = new Map(nodes.map(n => [n.svgId, n] as const));
const edgesByEquipmentId = new Map(edges.map(e => [e.equipmentId, e] as const));
const edgesByNode = new Map<string, EdgeMeta[]>();
edges.forEach(e => {
if (!edgesByNode.has(e.node1 as string)) edgesByNode.set(e.node1 as string, []);
edgesByNode.get(e.node1 as string)!.push(e);
if (!edgesByNode.has(e.node2 as string)) edgesByNode.set(e.node2 as string, []);
edgesByNode.get(e.node2 as string)!.push(e);
});
return { nodesByEquipmentId, nodesBySvgId, edgesByEquipmentId, edgesByNode };
};
const makeAction = (overrides: Partial<ActionDetail> = {}): ActionDetail => ({
description_unitaire: 'test action',
rho_before: null,
rho_after: null,
max_rho: null,
max_rho_line: '',
is_rho_reduction: false,
...overrides,
});
describe('buildActionOverviewPins', () => {
const metaIndex = makeOverviewMetaIndex();
it('resolves a line action to the edge midpoint', () => {
const actions: Record<string, ActionDetail> = {
'disco_LINE_A': makeAction({
action_topology: { lines_ex_bus: { LINE_A: -1 }, lines_or_bus: { LINE_A: -1 }, gens_bus: {}, loads_bus: {} },
max_rho: 0.88,
max_rho_line: 'LINE_B',
}),
};
const pins = buildActionOverviewPins(actions, metaIndex, 0.95);
expect(pins).toHaveLength(1);
// LINE_A midpoint = midpoint of (0,0) and (100,0) = (50, 0)
expect(pins[0]).toMatchObject({ id: 'disco_LINE_A', x: 50, y: 0 });
});
it('falls back to the voltage-level node for nodal actions', () => {
const actions: Record<string, ActionDetail> = {
'coupling_VL_FAR': makeAction({
description_unitaire: "Ouverture du poste 'VL_FAR'",
max_rho: 1.1,
max_rho_line: 'LINE_D',
}),
};
const pins = buildActionOverviewPins(actions, metaIndex, 0.95);
expect(pins).toHaveLength(1);
expect(pins[0]).toMatchObject({ id: 'coupling_VL_FAR', x: 500, y: 500 });
});
it('falls back to max_rho_line when no topology target resolves', () => {
const actions: Record<string, ActionDetail> = {
'mystery_action': makeAction({
description_unitaire: 'mystery',
max_rho: 0.9,
max_rho_line: 'LINE_D',
}),
};
const pins = buildActionOverviewPins(actions, metaIndex, 0.95);
expect(pins).toHaveLength(1);
// LINE_D midpoint = ((0+100)/2, (100+100)/2) = (50, 100)
expect(pins[0]).toMatchObject({ x: 50, y: 100 });
});
it('skips actions whose asset cannot be located', () => {
const actions: Record<string, ActionDetail> = {
'unknown': makeAction({ description_unitaire: 'floating', max_rho: 0.5, max_rho_line: 'GHOST_LINE' }),
};
const pins = buildActionOverviewPins(actions, metaIndex, 0.95);
expect(pins).toHaveLength(0);
});
it('assigns severity based on monitoringFactor', () => {
const actions: Record<string, ActionDetail> = {
'solved': makeAction({
action_topology: { lines_ex_bus: { LINE_A: -1 }, lines_or_bus: { LINE_A: -1 }, gens_bus: {}, loads_bus: {} },
max_rho: 0.5,
}),
'low_margin': makeAction({
action_topology: { lines_ex_bus: { LINE_B: -1 }, lines_or_bus: { LINE_B: -1 }, gens_bus: {}, loads_bus: {} },
max_rho: 0.92,
}),
'still_overloaded': makeAction({
action_topology: { lines_ex_bus: { LINE_C: -1 }, lines_or_bus: { LINE_C: -1 }, gens_bus: {}, loads_bus: {} },
max_rho: 1.1,
}),
'divergent': makeAction({
action_topology: { lines_ex_bus: { LINE_D: -1 }, lines_or_bus: { LINE_D: -1 }, gens_bus: {}, loads_bus: {} },
non_convergence: 'did not converge',
}),
};
const pins = buildActionOverviewPins(actions, metaIndex, 0.95);
const byId = Object.fromEntries(pins.map(p => [p.id, p] as const));
expect(byId['solved'].severity).toBe('green');
expect(byId['low_margin'].severity).toBe('orange');
expect(byId['still_overloaded'].severity).toBe('red');
expect(byId['divergent'].severity).toBe('grey');
});
it('labels pins with the rounded max loading percentage', () => {
const actions: Record<string, ActionDetail> = {
'a': makeAction({
action_topology: { lines_ex_bus: { LINE_A: -1 }, lines_or_bus: { LINE_A: -1 }, gens_bus: {}, loads_bus: {} },
max_rho: 0.873,
max_rho_line: 'LINE_A',
}),
};
const pins = buildActionOverviewPins(actions, metaIndex, 0.95);
expect(pins[0].label).toBe('87%');
});
it('honours the filterIds allowlist', () => {
const actions: Record<string, ActionDetail> = {
'a': makeAction({
action_topology: { lines_ex_bus: { LINE_A: -1 }, lines_or_bus: { LINE_A: -1 }, gens_bus: {}, loads_bus: {} },
max_rho: 0.5,
}),
'b': makeAction({
action_topology: { lines_ex_bus: { LINE_B: -1 }, lines_or_bus: { LINE_B: -1 }, gens_bus: {}, loads_bus: {} },
max_rho: 0.5,
}),
};
const pins = buildActionOverviewPins(actions, metaIndex, 0.95, ['a']);
expect(pins.map(p => p.id)).toEqual(['a']);
});
it('hides pins whose severity is disabled via the overview filter', () => {
const actions: Record<string, ActionDetail> = {
'green_a': makeAction({
action_topology: { lines_ex_bus: { LINE_A: -1 }, lines_or_bus: { LINE_A: -1 }, gens_bus: {}, loads_bus: {} },
max_rho: 0.5,
}),
'red_b': makeAction({
action_topology: { lines_ex_bus: { LINE_B: -1 }, lines_or_bus: { LINE_B: -1 }, gens_bus: {}, loads_bus: {} },
max_rho: 1.3,
}),
};
const pins = buildActionOverviewPins(actions, metaIndex, 0.95, undefined, {
categories: { green: true, orange: true, red: false, grey: true },
threshold: 2.0,
});
expect(pins.map(p => p.id)).toEqual(['green_a']);
});
it('hides pins whose max_rho exceeds the threshold', () => {
const actions: Record<string, ActionDetail> = {
'green_a': makeAction({
action_topology: { lines_ex_bus: { LINE_A: -1 }, lines_or_bus: { LINE_A: -1 }, gens_bus: {}, loads_bus: {} },
max_rho: 0.5,
}),
'red_b': makeAction({
action_topology: { lines_ex_bus: { LINE_B: -1 }, lines_or_bus: { LINE_B: -1 }, gens_bus: {}, loads_bus: {} },
max_rho: 1.6,
}),
};
const pins = buildActionOverviewPins(actions, metaIndex, 0.95, undefined, {
categories: { green: true, orange: true, red: true, grey: true },
threshold: 1.5,
});
expect(pins.map(p => p.id)).toEqual(['green_a']);
});
});
describe('actionPassesOverviewFilter', () => {
const cats = (overrides: Partial<{ green: boolean; orange: boolean; red: boolean; grey: boolean }> = {}) => ({
green: true, orange: true, red: true, grey: true, ...overrides,
});
it('returns true for a green action when every category is enabled', () => {
const detail = makeAction({ max_rho: 0.5 });
expect(actionPassesOverviewFilter(detail, 0.95, cats(), 1.5)).toBe(true);
});
it('returns false when the matching category is disabled', () => {
const detail = makeAction({ max_rho: 1.2 });
expect(actionPassesOverviewFilter(detail, 0.95, cats({ red: false }), 2.0)).toBe(false);
});
it('returns false when max_rho exceeds the threshold', () => {
const detail = makeAction({ max_rho: 1.8 });
expect(actionPassesOverviewFilter(detail, 0.95, cats(), 1.5)).toBe(false);
});
it('ignores the threshold for divergent/islanded actions (max_rho null)', () => {
const detail = makeAction({ max_rho: null, non_convergence: 'diverged' });
expect(actionPassesOverviewFilter(detail, 0.95, cats(), 1.0)).toBe(true);
});
it('hides divergent/islanded actions when the grey category is disabled', () => {
const detail = makeAction({ max_rho: null, is_islanded: true });
expect(actionPassesOverviewFilter(detail, 0.95, cats({ grey: false }), 2.0)).toBe(false);
});
});
describe('buildUnsimulatedActionPins', () => {
const metaIndex = makeOverviewMetaIndex();
it('creates dimmed pins for line ids resolved via edgesByEquipmentId', () => {
const pins = buildUnsimulatedActionPins(['LINE_A'], new Set(), metaIndex);
expect(pins).toHaveLength(1);
expect(pins[0].id).toBe('LINE_A');
expect(pins[0].unsimulated).toBe(true);
expect(pins[0].severity).toBe('grey');
// LINE_A midpoint = (50, 0)
expect(pins[0].x).toBe(50);
expect(pins[0].y).toBe(0);
});
it('creates dimmed pins for VL ids resolved via nodesByEquipmentId', () => {
const pins = buildUnsimulatedActionPins(['VL_FAR'], new Set(), metaIndex);
expect(pins).toHaveLength(1);
expect(pins[0].id).toBe('VL_FAR');
expect(pins[0].x).toBe(500);
expect(pins[0].y).toBe(500);
});
it('skips ids that cannot be resolved', () => {
const pins = buildUnsimulatedActionPins(['GHOST_LINE'], new Set(), metaIndex);
expect(pins).toHaveLength(0);
});
it('skips ids that are already simulated', () => {
const pins = buildUnsimulatedActionPins(['LINE_A', 'LINE_B'], new Set(['LINE_A']), metaIndex);
expect(pins.map(p => p.id)).toEqual(['LINE_B']);
});
it('dedupes repeated ids in the input list', () => {
const pins = buildUnsimulatedActionPins(['LINE_A', 'LINE_A'], new Set(), metaIndex);
expect(pins).toHaveLength(1);
});
it('uses a generic tooltip when no score info is provided', () => {
const pins = buildUnsimulatedActionPins(['LINE_A'], new Set(), metaIndex);
expect(pins[0].title).toBe('LINE_A — not yet simulated (double-click to run)');
});
it('enriches the tooltip with type, score, rank, max-in-category when score info is provided', () => {
const info = {
LINE_A: {
type: 'line_disconnection',
score: 0.82,
mwStart: null,
tapStart: null,
rankInType: 3,
countInType: 12,
maxScoreInType: 0.95,
},
};
const pins = buildUnsimulatedActionPins(['LINE_A'], new Set(), metaIndex, info);
expect(pins[0].title).toContain('Type: line_disconnection');
expect(pins[0].title).toContain('Score: 0.82');
expect(pins[0].title).toContain('rank 3 of 12');
expect(pins[0].title).toContain('max 0.95');
});
it('adds MW start to the tooltip when provided (load-shedding / curtailment)', () => {
const info = {
LINE_A: {
type: 'load_shedding',
score: 1.0,
mwStart: 24.5,
tapStart: null,
rankInType: 1,
countInType: 4,
maxScoreInType: 1.0,
},
};
const pins = buildUnsimulatedActionPins(['LINE_A'], new Set(), metaIndex, info);
expect(pins[0].title).toContain('MW start: 24.5 MW');
expect(pins[0].title).not.toContain('Tap start');
});
it('adds Tap start (with range) to the tooltip for PST actions', () => {
const info = {
LINE_A: {
type: 'pst_tap_change',
score: 0.7,
mwStart: null,
tapStart: { pst_name: 'PST_X', tap: 0, low_tap: -8, high_tap: 8 },
rankInType: 2,
countInType: 5,
maxScoreInType: 0.9,
},
};
const pins = buildUnsimulatedActionPins(['LINE_A'], new Set(), metaIndex, info);
expect(pins[0].title).toContain('Tap start: 0');
expect(pins[0].title).toContain('range -8 … 8');
});
it('falls back to the generic tooltip when the id is missing from scoreInfo', () => {
const info = {
OTHER_ID: {
type: 'line_disconnection', score: 0.5, mwStart: null, tapStart: null,
rankInType: 1, countInType: 1, maxScoreInType: 0.5,
},
};
const pins = buildUnsimulatedActionPins(['LINE_A'], new Set(), metaIndex, info);
expect(pins[0].title).toBe('LINE_A — not yet simulated (double-click to run)');
});
});
describe('applyActionOverviewPins', () => {
it('appends one <g.nad-action-overview-pin> per pin inside the svg', () => {
const container = document.createElement('div');
container.innerHTML = '<svg viewBox="0 0 200 200"></svg>';
applyActionOverviewPins(container, [
{ id: 'a', x: 10, y: 20, severity: 'green', label: '50%', title: 'A' },
{ id: 'b', x: 30, y: 40, severity: 'red', label: '120%', title: 'B' },
], () => {});
const pinGroups = container.querySelectorAll('g.nad-action-overview-pin');
expect(pinGroups.length).toBe(2);
const layer = container.querySelector('g.nad-action-overview-pins');
expect(layer).not.toBeNull();
});
it('uses the severity palette (green/orange/red/grey)', () => {
const container = document.createElement('div');
container.innerHTML = '<svg viewBox="0 0 200 200"></svg>';
applyActionOverviewPins(container, [
{ id: 'g', x: 0, y: 0, severity: 'green', label: '50%', title: '' },
{ id: 'o', x: 0, y: 0, severity: 'orange', label: '90%', title: '' },
{ id: 'r', x: 0, y: 0, severity: 'red', label: '110%', title: '' },
{ id: 'x', x: 0, y: 0, severity: 'grey', label: 'DIV', title: '' },
], () => {});
// path now lives inside the inner `pin-body` wrapper so the
// screen-constant rescaler can upscale it on unzoom. Use a
// descendant selector so the assertion keeps working.
const fills = Array.from(container.querySelectorAll('g.nad-action-overview-pin path'))
.map(el => el.getAttribute('fill'));
expect(fills).toEqual(['#28a745', '#f0ad4e', '#dc3545', '#9ca3af']);
});
it('draws pins WITHOUT an outline (stroke=none, no stroke-width)', () => {
// Regression guard for the "no black outline around pins"
// requirement — earlier versions drew a dark stroke and it
// competed visually with the NAD line strokes.
const container = document.createElement('div');
container.innerHTML = '<svg viewBox="0 0 200 200"></svg>';
applyActionOverviewPins(container, [
{ id: 'a', x: 10, y: 10, severity: 'green', label: '50%', title: '' },
], () => {});
const path = container.querySelector('g.nad-action-overview-pin path');
expect(path).not.toBeNull();
expect(path!.getAttribute('stroke')).toBe('none');
expect(path!.hasAttribute('stroke-width')).toBe(false);
});
it('uses a high-contrast dark slate fill on the pin label text (not the severity colour)', () => {
// Regression: earlier the label text was filled with the
// severity colour, which matched the teardrop and faded
// into the pin outline when the text slightly overflowed
// the inner white disc.
const container = document.createElement('div');
container.innerHTML = '<svg viewBox="0 0 200 200"></svg>';
applyActionOverviewPins(container, [
{ id: 'g', x: 0, y: 0, severity: 'green', label: '50%', title: '' },
{ id: 'o', x: 0, y: 0, severity: 'orange', label: '90%', title: '' },
{ id: 'r', x: 0, y: 0, severity: 'red', label: '110%', title: '' },
{ id: 'x', x: 0, y: 0, severity: 'grey', label: 'DIV', title: '' },
], () => {});
const texts = Array.from(container.querySelectorAll('g.nad-action-overview-pin text'));
// Every pin label — regardless of severity — uses the
// same dark fill so it stays readable on the white disc
// AND on the coloured teardrop if it overflows.
texts.forEach(t => {
expect(t.getAttribute('fill')).toBe('#1f2937');
});
});
it('is idempotent — re-applying wipes the previous layer', () => {
const container = document.createElement('div');
container.innerHTML = '<svg viewBox="0 0 200 200"></svg>';
applyActionOverviewPins(container, [
{ id: 'a', x: 0, y: 0, severity: 'green', label: '50%', title: '' },
{ id: 'b', x: 10, y: 10, severity: 'red', label: '110%', title: '' },
], () => {});
applyActionOverviewPins(container, [
{ id: 'c', x: 20, y: 20, severity: 'orange', label: '92%', title: '' },
], () => {});
const layers = container.querySelectorAll('g.nad-action-overview-pins');
expect(layers.length).toBe(1);
const pinGroups = container.querySelectorAll('g.nad-action-overview-pin');
expect(pinGroups.length).toBe(1);
expect(pinGroups[0].getAttribute('data-action-id')).toBe('c');
});
it('empty pin list clears the layer entirely', () => {
const container = document.createElement('div');
container.innerHTML = '<svg viewBox="0 0 200 200"></svg>';
applyActionOverviewPins(container, [
{ id: 'a', x: 0, y: 0, severity: 'green', label: '50%', title: '' },
], () => {});
applyActionOverviewPins(container, [], () => {});
expect(container.querySelectorAll('g.nad-action-overview-pin').length).toBe(0);
});
it('clicking a pin invokes onPinClick (deferred) with the action id and screen position', () => {
vi.useFakeTimers();
try {
const container = document.createElement('div');
container.innerHTML = '<svg viewBox="0 0 200 200"></svg>';
const clicks: Array<{ id: string; pos: { x: number; y: number } }> = [];
applyActionOverviewPins(container, [
{ id: 'action_42', x: 10, y: 10, severity: 'green', label: '50%', title: '' },
], (id, pos) => clicks.push({ id, pos }));
const pinGroup = container.querySelector('g.nad-action-overview-pin') as SVGGElement;
pinGroup.dispatchEvent(new MouseEvent('click', { bubbles: true }));
// Deferred: the callback has NOT fired yet because the
// 250 ms single-click delay is still pending.
expect(clicks).toEqual([]);
vi.advanceTimersByTime(PIN_SINGLE_CLICK_DELAY_MS + 10);
expect(clicks.length).toBe(1);
expect(clicks[0].id).toBe('action_42');
// Screen position is derived from getBoundingClientRect;
// jsdom returns zeros but the object shape is preserved.
expect(clicks[0].pos).toHaveProperty('x');
expect(clicks[0].pos).toHaveProperty('y');
} finally {
vi.useRealTimers();
}
});
it('double-clicking a pin cancels the pending single-click and fires onPinDoubleClick', () => {
vi.useFakeTimers();
try {
const container = document.createElement('div');
container.innerHTML = '<svg viewBox="0 0 200 200"></svg>';
const singleClicks: string[] = [];
const doubleClicks: string[] = [];
applyActionOverviewPins(
container,
[{ id: 'action_42', x: 10, y: 10, severity: 'green', label: '50%', title: '' }],
id => singleClicks.push(id),
id => doubleClicks.push(id),
);
const pinGroup = container.querySelector('g.nad-action-overview-pin') as SVGGElement;
// A real browser double-click sends two click events followed
// by dblclick. The first click schedules the timer, the
// second is ignored (timer already pending), then dblclick
// clears the timer and fires the double-click callback.
pinGroup.dispatchEvent(new MouseEvent('click', { bubbles: true }));
pinGroup.dispatchEvent(new MouseEvent('click', { bubbles: true }));
pinGroup.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }));
vi.advanceTimersByTime(PIN_SINGLE_CLICK_DELAY_MS + 10);
expect(singleClicks).toEqual([]);
expect(doubleClicks).toEqual(['action_42']);
} finally {
vi.useRealTimers();
}
});
it('does nothing when the container has no <svg>', () => {
const container = document.createElement('div');
// Should NOT throw
expect(() => applyActionOverviewPins(container, [
{ id: 'a', x: 0, y: 0, severity: 'green', label: '50%', title: '' },
], () => {})).not.toThrow();
});
it('stops mousedown propagation so usePanZoom drag does not eat the click', () => {
// Regression: if mousedown bubbles up to the svg-container,
// usePanZoom calls setInteracting(true) which sets
// pointer-events: none on every svg child via App.css. The
// pin's click never lands. We assert here that mousedown
// is stopped at the pin (propagation chain truncated).
const container = document.createElement('div');
container.innerHTML = '<svg viewBox="0 0 200 200"></svg>';
applyActionOverviewPins(container, [
{ id: 'a', x: 10, y: 10, severity: 'green', label: '50%', title: '' },
], () => {});
const pin = container.querySelector('g.nad-action-overview-pin') as SVGGElement;
let bubbled = false;
// Listen on the svg ancestor — if propagation is properly
// stopped at the pin, this listener will NOT fire.
const svg = container.querySelector('svg')!;
svg.addEventListener('mousedown', () => { bubbled = true; });
pin.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
expect(bubbled).toBe(false);
});
});
describe('applyActionOverviewHighlights', () => {
const buildContainer = (): { container: HTMLElement; meta: MetadataIndex } => {
const container = document.createElement('div');
container.innerHTML =
'<svg viewBox="0 0 1000 1000">' +
' <g class="nad-edges">' +
' <g id="svg-cont"><line x1="0" y1="0" x2="100" y2="0"/></g>' +
' <g id="svg-ovl-1"><line x1="0" y1="100" x2="100" y2="100"/></g>' +
' <g id="svg-ovl-2"><line x1="0" y1="200" x2="100" y2="200"/></g>' +
' </g>' +
'</svg>';
const meta: MetadataIndex = {
nodesByEquipmentId: new Map(),
nodesBySvgId: new Map(),
edgesByEquipmentId: new Map<string, EdgeMeta>([
['CONT_LINE', { equipmentId: 'CONT_LINE', svgId: 'svg-cont', node1: '', node2: '' }],
['OVL_1', { equipmentId: 'OVL_1', svgId: 'svg-ovl-1', node1: '', node2: '' }],
['OVL_2', { equipmentId: 'OVL_2', svgId: 'svg-ovl-2', node1: '', node2: '' }],
]),
edgesByNode: new Map(),
};
return { container, meta };
};
it('creates a .nad-overview-highlight-layer with one clone per highlighted edge', () => {
const { container, meta } = buildContainer();
applyActionOverviewHighlights(container, meta, 'CONT_LINE', ['OVL_1', 'OVL_2']);
const layer = container.querySelector('g.nad-overview-highlight-layer');
expect(layer).not.toBeNull();
// 1 contingency + 2 overloads = 3 clones
expect(layer!.children.length).toBe(3);
});
it('uses the existing nad-contingency-highlight class on the contingency clone', () => {
const { container, meta } = buildContainer();
applyActionOverviewHighlights(container, meta, 'CONT_LINE', []);
const clone = container.querySelector('g.nad-overview-highlight-layer .nad-contingency-highlight');
expect(clone).not.toBeNull();
expect(clone!.classList.contains('nad-highlight-clone')).toBe(true);
});
it('uses the existing nad-overloaded class on overload clones', () => {
const { container, meta } = buildContainer();
applyActionOverviewHighlights(container, meta, null, ['OVL_1']);
const clone = container.querySelector('g.nad-overview-highlight-layer .nad-overloaded');
expect(clone).not.toBeNull();
expect(clone!.classList.contains('nad-highlight-clone')).toBe(true);
});
it('inserts the highlight layer at the START of the SVG (behind NAD content)', () => {
const { container, meta } = buildContainer();
applyActionOverviewHighlights(container, meta, 'CONT_LINE', []);
const svg = container.querySelector('svg')!;
const layer = svg.querySelector(':scope > g.nad-overview-highlight-layer');
expect(layer).not.toBeNull();
// Highlight layer should be the first child (behind everything).
expect(svg.firstElementChild).toBe(layer);
});
it('inserts highlight layer BEFORE existing dim rect and pin layer', () => {
const { container, meta } = buildContainer();
const svg = container.querySelector('svg')!;
const SVG_NS = 'http://www.w3.org/2000/svg';
// Simulate dim rect and pin layer already present (as in the real component).
const dimRect = document.createElementNS(SVG_NS, 'rect');
dimRect.setAttribute('class', 'nad-overview-dim-rect');
svg.appendChild(dimRect);
const pinLayer = document.createElementNS(SVG_NS, 'g');
pinLayer.setAttribute('class', 'nad-action-overview-pins');
svg.appendChild(pinLayer);
applyActionOverviewHighlights(container, meta, 'CONT_LINE', ['OVL_1']);
const children = Array.from(svg.children);
const highlightIdx = children.findIndex(c => c.classList.contains('nad-overview-highlight-layer'));
const dimIdx = children.findIndex(c => c.classList.contains('nad-overview-dim-rect'));
const pinIdx = children.findIndex(c => c.classList.contains('nad-action-overview-pins'));
// Highlights behind NAD content (at start), dim rect and pins after
expect(highlightIdx).toBe(0);
expect(highlightIdx).toBeLessThan(dimIdx);
expect(dimIdx).toBeLessThan(pinIdx);
});
it('re-inserts highlight layer at SVG start on idempotent re-call', () => {
const { container, meta } = buildContainer();
applyActionOverviewHighlights(container, meta, 'CONT_LINE', ['OVL_1']);
applyActionOverviewHighlights(container, meta, 'CONT_LINE', ['OVL_1', 'OVL_2']);
const svg = container.querySelector('svg')!;
const layer = svg.querySelector(':scope > g.nad-overview-highlight-layer');
// Should still be at the start after the second call.
expect(svg.firstElementChild).toBe(layer);
});
it('is idempotent — repeated calls wipe the previous highlight layer', () => {
const { container, meta } = buildContainer();
applyActionOverviewHighlights(container, meta, 'CONT_LINE', ['OVL_1', 'OVL_2']);
applyActionOverviewHighlights(container, meta, 'CONT_LINE', ['OVL_1']);
const layers = container.querySelectorAll('g.nad-overview-highlight-layer');
expect(layers.length).toBe(1);
expect(layers[0].children.length).toBe(2); // 1 contingency + 1 overload
});
it('clears the highlight layer when called with neither contingency nor overloads', () => {
const { container, meta } = buildContainer();
applyActionOverviewHighlights(container, meta, 'CONT_LINE', ['OVL_1']);
applyActionOverviewHighlights(container, meta, null, []);
expect(container.querySelector('g.nad-overview-highlight-layer')).toBeNull();
});
it('skips equipment ids that are not in the metadata', () => {
const { container, meta } = buildContainer();
applyActionOverviewHighlights(container, meta, 'GHOST', ['OVL_1', 'GHOST2']);
const layer = container.querySelector('g.nad-overview-highlight-layer')!;
// Only OVL_1 resolved → 1 clone
expect(layer.children.length).toBe(1);
});
it('no-ops gracefully on null container / metaIndex / svg', () => {
expect(() => applyActionOverviewHighlights(null, null, null, [])).not.toThrow();
expect(() => applyActionOverviewHighlights(document.createElement('div'), null, 'X', ['Y'])).not.toThrow();
const empty = document.createElement('div');
expect(() => applyActionOverviewHighlights(empty, { nodesByEquipmentId: new Map(), nodesBySvgId: new Map(), edgesByEquipmentId: new Map(), edgesByNode: new Map() }, 'X', [])).not.toThrow();
});
it('wraps pin glyph inside a .nad-action-overview-pin-body subgroup for rescaling', () => {
const container = document.createElement('div');
container.innerHTML = '<svg viewBox="0 0 200 200"></svg>';
applyActionOverviewPins(container, [
{ id: 'a', x: 10, y: 20, severity: 'green', label: '50%', title: 'A' },
], () => {});
const pin = container.querySelector('g.nad-action-overview-pin')!;
// Outer group: translate only (no scale at this level)
expect(pin.getAttribute('transform')).toBe('translate(10 20)');
// Inner body group: carries the rescaling scale() transform
const body = pin.querySelector(':scope > g.nad-action-overview-pin-body');
expect(body).not.toBeNull();
expect(body!.getAttribute('transform')).toMatch(/^scale\(/);
// The glyph (path, inner disc, text) all live inside the body
expect(body!.querySelector('path')).not.toBeNull();
expect(body!.querySelector('circle')).not.toBeNull();
expect(body!.querySelector('text')).not.toBeNull();
});
it('uses the VL circle radius from the SVG as the pin base radius', () => {
// Large-grid NAD: VL circles have r=80 (after boost).
// The pin teardrop path must reference R=80 in its `d`
// attribute so it matches VL circles at 1:1 zoom.
const container = document.createElement('div');
container.innerHTML =
'<svg viewBox="0 0 200000 200000">' +
' <g class="nad-vl-nodes"><circle r="80"/></g>' +
'</svg>';
applyActionOverviewPins(container, [
{ id: 'a', x: 0, y: 0, severity: 'green', label: '50%', title: '' },
], () => {});
const path = container.querySelector('g.nad-action-overview-pin path')!;
// Teardrop arc endpoints: A ${R} ${R} … ${R} ${-R-tail}
// With R=80, the d attribute must reference "80" in the arc.
expect(path.getAttribute('d')).toContain('A 80 80');
});
it('falls back to r=30 when the SVG has no usable VL circles', () => {
const container = document.createElement('div');
container.innerHTML = '<svg viewBox="0 0 200 200"></svg>';
applyActionOverviewPins(container, [
{ id: 'a', x: 0, y: 0, severity: 'green', label: '50%', title: '' },
], () => {});
const path = container.querySelector('g.nad-action-overview-pin path')!;
expect(path.getAttribute('d')).toContain('A 30 30');
});
});
describe('rescaleActionOverviewPins', () => {
const renderContainer = (svgInner: string = ''): HTMLElement => {
const container = document.createElement('div');
container.innerHTML =
'<svg viewBox="0 0 1000 1000">' +
' <g class="nad-vl-nodes"><circle r="30"/></g>' +
svgInner +
'</svg>';
return container;
};
it('is a no-op when the container has no pin layer', () => {
const container = renderContainer();
// Should NOT throw even though there's no pin layer yet.
expect(() => rescaleActionOverviewPins(container)).not.toThrow();
});
it('is a no-op when the container has no <svg>', () => {
const container = document.createElement('div');
expect(() => rescaleActionOverviewPins(container)).not.toThrow();
});
it('applies a scale(1) body transform at 1:1 mapping (jsdom fallback)', () => {
// jsdom does not implement getScreenCTM, so the rescaler
// falls back to pxPerSvgUnit=1. With baseR=30 (VL circle)
// and MIN_SCREEN_RADIUS_PX=22, baseR wins as the floor and
// the body scale stays at 1.
const container = renderContainer();
applyActionOverviewPins(container, [
{ id: 'a', x: 50, y: 50, severity: 'green', label: '50%', title: '' },
], () => {});
rescaleActionOverviewPins(container);
const body = container.querySelector('g.nad-action-overview-pin-body')!;
expect(body.getAttribute('transform')).toBe('scale(1)');
});
it('upscales the pin body when the zoom level puts VL circles below the screen-pixel floor', () => {
// Simulate a very zoomed-out NAD: viewBox is 1000 wide but the
// container is only 100 px wide → pxPerSvgUnit = 100 / 1000 = 0.1.
// At that ratio, baseR=30 → 3 screen px, well below the
// 22-px floor, so the rescaler must upscale the body.
const container = renderContainer();
applyActionOverviewPins(container, [
{ id: 'a', x: 0, y: 0, severity: 'green', label: '50%', title: '' },
], () => {});
// Mock clientWidth on the container (jsdom reports 0 by default).
// viewBox="0 0 1000 1000", clientWidth=100 → pxPerSvgUnit = 0.1
Object.defineProperty(container, 'clientWidth', { value: 100, configurable: true });
rescaleActionOverviewPins(container);
const body = container.querySelector('g.nad-action-overview-pin-body')!;
const match = body.getAttribute('transform')!.match(/^scale\(([0-9.]+)\)$/);
expect(match).not.toBeNull();
const scale = parseFloat(match![1]);
// Effective SVG radius = 22 px / 0.1 px/unit = 220 units.
// Scale = 220 / 30 ≈ 7.33.
expect(scale).toBeCloseTo(220 / 30, 2);
});
it('keeps scale=1 when zoom level is detailed enough (VL circles already above floor)', () => {
const container = renderContainer();
applyActionOverviewPins(container, [
{ id: 'a', x: 0, y: 0, severity: 'green', label: '50%', title: '' },
], () => {});
// viewBox="0 0 1000 1000", clientWidth=2000 → pxPerSvgUnit = 2
// → 30 units = 60 screen px, way above the 22 px floor.
Object.defineProperty(container, 'clientWidth', { value: 2000, configurable: true });
rescaleActionOverviewPins(container);
const body = container.querySelector('g.nad-action-overview-pin-body')!;
expect(body.getAttribute('transform')).toBe('scale(1)');
});
it('rescales EVERY pin on the layer, not just the first', () => {
const container = renderContainer();
applyActionOverviewPins(container, [
{ id: 'a', x: 0, y: 0, severity: 'green', label: '50%', title: '' },
{ id: 'b', x: 100, y: 0, severity: 'red', label: '110%', title: '' },
{ id: 'c', x: 200, y: 0, severity: 'orange', label: '92%', title: '' },
], () => {});
// viewBox="0 0 1000 1000", clientWidth=100 → pxPerSvgUnit = 0.1
Object.defineProperty(container, 'clientWidth', { value: 100, configurable: true });
rescaleActionOverviewPins(container);
const bodies = container.querySelectorAll('g.nad-action-overview-pin-body');
expect(bodies.length).toBe(3);
const scales = Array.from(bodies).map(b => b.getAttribute('transform'));
// All three pins share the same screen-constant scale.
expect(new Set(scales).size).toBe(1);
expect(scales[0]).not.toBe('scale(1)');
});
it('is automatically invoked by applyActionOverviewPins for initial sizing', () => {
// If we never called rescaleActionOverviewPins directly,
// the pin body should still have a transform attribute —
// applyActionOverviewPins calls the rescaler at the end so
// the first paint is already compensated.
const container = renderContainer();
applyActionOverviewPins(container, [
{ id: 'a', x: 0, y: 0, severity: 'green', label: '50%', title: '' },
], () => {});
const body = container.querySelector('g.nad-action-overview-pin-body')!;
expect(body.hasAttribute('transform')).toBe(true);
});
});
describe('computeActionOverviewFitRect', () => {
const metaIndex = makeOverviewMetaIndex();
it('returns null when nothing can be located', () => {
expect(computeActionOverviewFitRect(metaIndex, null, [], [])).toBeNull();
});
it('includes the contingency edge endpoints in the rectangle', () => {
// LINE_A spans (0,0)→(100,0). Both spans are below
// MIN_SPAN=200 so they're expanded around their centers:
// x: span 100 → 200 centered on 50 → [-50, 150]
// y: span 0 → 200 centered on 0 → [-100, 100]
const rect = computeActionOverviewFitRect(metaIndex, 'LINE_A', [], [], 0);
expect(rect!.x).toBeCloseTo(-50);
expect(rect!.w).toBeCloseTo(200);
expect(rect!.y).toBeCloseTo(-100);
expect(rect!.h).toBeCloseTo(200);
});
it('unions contingency + overloads + pins', () => {
const rect = computeActionOverviewFitRect(
metaIndex,
'LINE_A', // (0,0)..(100,0)
['LINE_D'], // (0,100)..(100,100)
[{ x: 500, y: 500 }],
0,
);
expect(rect!.x).toBeCloseTo(0);
expect(rect!.y).toBeCloseTo(0);
expect(rect!.w).toBeCloseTo(500);
expect(rect!.h).toBeCloseTo(500);
});
it('applies a 5% margin by default', () => {
// Regression guard for the "5% margin" requirement.
const rect = computeActionOverviewFitRect(
metaIndex,
null,
[],
[{ x: 0, y: 0 }, { x: 1000, y: 1000 }],
);
// Raw bbox is 1000x1000. With 5% padding on each side we
// expect: x = -50, y = -50, w = 1100, h = 1100.
expect(rect!.x).toBeCloseTo(-50);
expect(rect!.y).toBeCloseTo(-50);
expect(rect!.w).toBeCloseTo(1100);
expect(rect!.h).toBeCloseTo(1100);
});
it('expands degenerate spans (single point) to a minimum size', () => {
const rect = computeActionOverviewFitRect(
metaIndex,
null,
[],
[{ x: 10, y: 20 }],
0,
);
// Single point: w=h<MIN_SPAN=200 → expanded to 200 around center
expect(rect!.w).toBeCloseTo(200);
expect(rect!.h).toBeCloseTo(200);
expect(rect!.x).toBeCloseTo(10 - 100);
expect(rect!.y).toBeCloseTo(20 - 100);
});
it('returns null when metaIndex is null', () => {
expect(computeActionOverviewFitRect(null, 'LINE_A', [], [])).toBeNull();
});
});
describe('computeEquipmentFitRect', () => {
const metaIndex = makeOverviewMetaIndex();
it('focuses on an edge using both endpoints', () => {
const rect = computeEquipmentFitRect(metaIndex, 'LINE_A', 0);
// LINE_A spans (0,0)→(100,0); both spans below MIN_SPAN=150
// → expanded around center (50, 0): x ∈ [-25, 125], y ∈ [-75, 75].
expect(rect!.x).toBeCloseTo(-25);
expect(rect!.w).toBeCloseTo(150);
expect(rect!.y).toBeCloseTo(-75);
expect(rect!.h).toBeCloseTo(150);
});
it('focuses on a voltage-level node', () => {
const rect = computeEquipmentFitRect(metaIndex, 'VL_FAR', 0);
// Single point at (500,500) → expanded to 150x150 around center
expect(rect!.x).toBeCloseTo(500 - 75);
expect(rect!.y).toBeCloseTo(500 - 75);
expect(rect!.w).toBeCloseTo(150);
expect(rect!.h).toBeCloseTo(150);
});
it('returns null for an unknown equipment id', () => {
expect(computeEquipmentFitRect(metaIndex, 'UNKNOWN', 0)).toBeNull();
});
it('returns null when metaIndex is null', () => {
expect(computeEquipmentFitRect(null, 'LINE_A', 0)).toBeNull();
});
});
// ============================================================
// Performance-critical batching & caching tests
// ============================================================
describe('applyActionOverviewHighlights — batched DOM writes', () => {
const buildContainer = (): { container: HTMLElement; meta: MetadataIndex } => {
const container = document.createElement('div');
container.innerHTML =
'<svg viewBox="0 0 1000 1000">' +
' <g class="nad-edges">' +
' <g id="svg-cont"><line x1="0" y1="0" x2="100" y2="0"/></g>' +
' <g id="svg-ovl-1"><line x1="0" y1="100" x2="100" y2="100"/></g>' +
' <g id="svg-ovl-2"><line x1="0" y1="200" x2="100" y2="200"/></g>' +
' </g>' +
'</svg>';
const meta: MetadataIndex = {
nodesByEquipmentId: new Map(),
nodesBySvgId: new Map(),
edgesByEquipmentId: new Map<string, EdgeMeta>([
['CONT_LINE', { equipmentId: 'CONT_LINE', svgId: 'svg-cont', node1: '', node2: '' }],
['OVL_1', { equipmentId: 'OVL_1', svgId: 'svg-ovl-1', node1: '', node2: '' }],
['OVL_2', { equipmentId: 'OVL_2', svgId: 'svg-ovl-2', node1: '', node2: '' }],
]),
edgesByNode: new Map(),
};
return { container, meta };
};
it('inserts all clones in a single DOM mutation (no interleaved appendChild)', () => {
const { container, meta } = buildContainer();
const svg = container.querySelector('svg')!;
// Count how many times the highlight layer's children change.
// With the batched DocumentFragment approach, the layer gets
// a single appendChild(frag) at the end.
let insertCount = 0;
// The highlight layer is inserted via appendChild (when no
// pin layer exists) or insertBefore (when pin layer exists).
// Spy on both paths to catch the layer insertion, then spy
// on the layer's appendChild to count clone insertions.
const spyOnLayer = (node: Node) => {
if (node instanceof Element && node.classList?.contains('nad-overview-highlight-layer')) {
const origAppend = node.appendChild.bind(node);
node.appendChild = function <U extends Node>(child: U): U {
insertCount++;
return origAppend(child);
};
}
};
const realAppendChild = svg.appendChild.bind(svg);
svg.appendChild = function <T extends Node>(node: T): T {
const result = realAppendChild(node);
spyOnLayer(node);
return result;
};
const realInsertBefore = svg.insertBefore.bind(svg);
svg.insertBefore = function <T extends Node>(node: T, ref: Node | null): T {
const result = realInsertBefore(node, ref);
spyOnLayer(node);
return result;
};
applyActionOverviewHighlights(container, meta, 'CONT_LINE', ['OVL_1', 'OVL_2']);
// With DocumentFragment batching, there should be exactly 1
// appendChild call on the layer (the fragment), not 3 (one
// per clone).
expect(insertCount).toBe(1);
// But all 3 clones still appear in the DOM:
const layer = container.querySelector('g.nad-overview-highlight-layer')!;
expect(layer.children.length).toBe(3);
});
it('strips nad-delta-* classes from cloned highlights', () => {
const { container, meta } = buildContainer();
// Tag an original edge with delta classes that should be
// stripped on the clone.
const orig = container.querySelector('#svg-ovl-1')!;
orig.classList.add('nad-delta-positive');
applyActionOverviewHighlights(container, meta, null, ['OVL_1']);
const clone = container.querySelector('g.nad-overview-highlight-layer .nad-overloaded')!;
expect(clone.classList.contains('nad-delta-positive')).toBe(false);
expect(clone.classList.contains('nad-delta-negative')).toBe(false);
expect(clone.classList.contains('nad-delta-grey')).toBe(false);
});
});
describe('applyActionOverviewPins — batched DOM writes', () => {
it('builds all pins off-DOM before inserting the layer into the SVG', () => {
const container = document.createElement('div');
container.innerHTML = '<svg viewBox="0 0 200 200"></svg>';
const svg = container.querySelector('svg')!;
// Track appendChild calls on the SVG itself.
let svgAppendCount = 0;
const origAppend = svg.appendChild.bind(svg);
svg.appendChild = function <T extends Node>(child: T): T {
svgAppendCount++;
return origAppend(child);
};
applyActionOverviewPins(container, [
{ id: 'a', x: 10, y: 20, severity: 'green', label: '50%', title: 'A' },
{ id: 'b', x: 30, y: 40, severity: 'red', label: '120%', title: 'B' },
{ id: 'c', x: 50, y: 60, severity: 'orange', label: '90%', title: 'C' },
], () => {});
// Only ONE appendChild on the SVG (the fully-populated layer),
// not one per pin.
expect(svgAppendCount).toBe(1);
// All 3 pins are present in the DOM:
expect(container.querySelectorAll('g.nad-action-overview-pin').length).toBe(3);
});
});
describe('rescaleActionOverviewPins — baseRadius caching', () => {
it('does not re-query circle[r] on subsequent rescale calls', () => {
const container = document.createElement('div');
container.innerHTML =
'<svg viewBox="0 0 1000 1000">' +
' <g class="nad-vl-nodes"><circle r="30"/></g>' +
'</svg>';
applyActionOverviewPins(container, [
{ id: 'a', x: 50, y: 50, severity: 'green', label: '50%', title: '' },
], () => {});
const svg = container.querySelector('svg')!;
// After applyActionOverviewPins, the cache is populated.
// Spy on querySelector to verify it's not called for circle[r]
// during rescale.
const origQS = svg.querySelector.bind(svg);
let circleQueryCount = 0;
svg.querySelector = function (selector: string) {
if (selector.includes('circle[r]')) circleQueryCount++;
return origQS(selector);
} as typeof svg.querySelector;
rescaleActionOverviewPins(container);
rescaleActionOverviewPins(container);
rescaleActionOverviewPins(container);
// The cached path should skip the circle[r] lookup entirely.
expect(circleQueryCount).toBe(0);
});
});
// ============================================================
// Pin selection/rejection symbols, combined-action pins, and
// load-shedding/curtailment VL anchoring
// ============================================================
describe('buildActionOverviewPins — combined pair exclusion', () => {
const metaIndex = makeOverviewMetaIndex();
it('skips action IDs containing "+" (combined pairs)', () => {
const actions: Record<string, ActionDetail> = {
'disco_LINE_A': makeAction({ max_rho: 0.5, action_topology: { lines_ex_bus: { LINE_A: -1 }, lines_or_bus: {}, gens_bus: {}, loads_bus: {} } }),
'reco_LINE_B': makeAction({ max_rho: 0.6, action_topology: { lines_ex_bus: { LINE_B: 1 }, lines_or_bus: {}, gens_bus: {}, loads_bus: {} } }),
'disco_LINE_A+reco_LINE_B': makeAction({ max_rho: 0.4 }),
};
const pins = buildActionOverviewPins(actions, metaIndex, 0.95);
const ids = pins.map(p => p.id);
expect(ids).toContain('disco_LINE_A');
expect(ids).toContain('reco_LINE_B');
expect(ids).not.toContain('disco_LINE_A+reco_LINE_B');
expect(pins).toHaveLength(2);
});
});
describe('buildActionOverviewPins — load shedding / curtailment VL anchoring', () => {
const metaIndex = makeOverviewMetaIndex();
it('anchors load shedding actions on the voltage level node', () => {
const actions: Record<string, ActionDetail> = {
'load_shedding_X': makeAction({
max_rho: 0.8,
load_shedding_details: [{ load_name: 'LOAD_1', voltage_level_id: 'VL_FAR', shedded_mw: 50 }],
}),
};
const pins = buildActionOverviewPins(actions, metaIndex, 0.95);
expect(pins).toHaveLength(1);
expect(pins[0].x).toBe(500);
expect(pins[0].y).toBe(500);
});
it('anchors curtailment actions on the voltage level node', () => {
const actions: Record<string, ActionDetail> = {
'curtail_GEN': makeAction({
max_rho: 0.85,
curtailment_details: [{ gen_name: 'GEN_1', voltage_level_id: 'VL_N3', curtailed_mw: 30 }],
}),
};
const pins = buildActionOverviewPins(actions, metaIndex, 0.95);
expect(pins).toHaveLength(1);
expect(pins[0].x).toBe(0);
expect(pins[0].y).toBe(100);
});
it('falls back to generic resolution when voltage_level_id is null', () => {
const actions: Record<string, ActionDetail> = {
'load_shedding_X': makeAction({
max_rho: 0.7,
max_rho_line: 'LINE_A',
load_shedding_details: [{ load_name: 'LOAD_1', voltage_level_id: null, shedded_mw: 50 }],
}),
};
const pins = buildActionOverviewPins(actions, metaIndex, 0.95);
expect(pins).toHaveLength(1);
// Falls back to max_rho_line (LINE_A midpoint)
expect(pins[0].x).toBe(50);
expect(pins[0].y).toBe(0);
});
});
describe('buildActionOverviewPins — overlapping pin fan-out', () => {
const metaIndex = makeOverviewMetaIndex();
it('fans out pins sharing the same anchor position', () => {
const actions: Record<string, ActionDetail> = {
'action_1': makeAction({ max_rho: 0.5, max_rho_line: 'LINE_A' }),
'action_2': makeAction({ max_rho: 0.6, max_rho_line: 'LINE_A' }),
};
const pins = buildActionOverviewPins(actions, metaIndex, 0.95);
expect(pins).toHaveLength(2);
// Both would have anchored on LINE_A midpoint (50, 0) but should be offset
const dx = pins[0].x - pins[1].x;
const dy = pins[0].y - pins[1].y;
const dist = Math.sqrt(dx * dx + dy * dy);
expect(dist).toBeGreaterThan(0);
});
it('does not offset pins at different positions', () => {
const actions: Record<string, ActionDetail> = {
'disco_LINE_A': makeAction({ max_rho: 0.5, action_topology: { lines_ex_bus: { LINE_A: -1 }, lines_or_bus: {}, gens_bus: {}, loads_bus: {} } }),
'disco_LINE_B': makeAction({ max_rho: 0.6, action_topology: { lines_ex_bus: { LINE_B: -1 }, lines_or_bus: {}, gens_bus: {}, loads_bus: {} } }),
};
const pins = buildActionOverviewPins(actions, metaIndex, 0.95);
expect(pins).toHaveLength(2);
// LINE_A midpoint = (50,0), LINE_B midpoint = (100,50) — different, no fan-out
const pinA = pins.find(p => p.id === 'disco_LINE_A')!;
const pinB = pins.find(p => p.id === 'disco_LINE_B')!;
expect(pinA.x).toBe(50);
expect(pinA.y).toBe(0);
expect(pinB.x).toBe(100);
expect(pinB.y).toBe(50);
});
});
describe('buildCombinedActionPins', () => {
const metaIndex = makeOverviewMetaIndex();
const makeUnitaryPins = () => buildActionOverviewPins({
'disco_LINE_A': makeAction({ max_rho: 0.5, action_topology: { lines_ex_bus: { LINE_A: -1 }, lines_or_bus: {}, gens_bus: {}, loads_bus: {} } }),
'reco_LINE_B': makeAction({ max_rho: 0.6, action_topology: { lines_ex_bus: { LINE_B: 1 }, lines_or_bus: {}, gens_bus: {}, loads_bus: {} } }),
}, metaIndex, 0.95);
it('returns empty array when actions is null', () => {
expect(buildCombinedActionPins(null, [], 0.95)).toEqual([]);
});
it('returns empty array when no action keys contain "+"', () => {
const actions: Record<string, ActionDetail> = {
'disco_LINE_A': makeAction({ max_rho: 0.5 }),
};
expect(buildCombinedActionPins(actions, makeUnitaryPins(), 0.95)).toEqual([]);
});
it('builds a combined pin when a "+" key exists and both constituents have pins', () => {
const unitaryPins = makeUnitaryPins();
const actions: Record<string, ActionDetail> = {
'disco_LINE_A': makeAction({ max_rho: 0.5 }),
'reco_LINE_B': makeAction({ max_rho: 0.6 }),
'disco_LINE_A+reco_LINE_B': makeAction({ max_rho: 0.45, max_rho_line: 'LINE_C', description_unitaire: 'Combined' }),
};
const combined = buildCombinedActionPins(actions, unitaryPins, 0.95);
expect(combined).toHaveLength(1);
expect(combined[0].pairId).toBe('disco_LINE_A+reco_LINE_B');
expect(combined[0].action1Id).toBe('disco_LINE_A');
expect(combined[0].action2Id).toBe('reco_LINE_B');
});
it('places the combined pin at the Bezier midpoint, not on either constituent', () => {
const unitaryPins = makeUnitaryPins();
const pin1 = unitaryPins.find(p => p.id === 'disco_LINE_A')!;
const pin2 = unitaryPins.find(p => p.id === 'reco_LINE_B')!;
const actions: Record<string, ActionDetail> = {
'disco_LINE_A': makeAction({ max_rho: 0.5 }),
'reco_LINE_B': makeAction({ max_rho: 0.6 }),
'disco_LINE_A+reco_LINE_B': makeAction({ max_rho: 0.45 }),
};
const combined = buildCombinedActionPins(actions, unitaryPins, 0.95);
const cp = combined[0];
// Must not be at either constituent position
const atPin1 = cp.x === pin1.x && cp.y === pin1.y;
const atPin2 = cp.x === pin2.x && cp.y === pin2.y;
expect(atPin1).toBe(false);
expect(atPin2).toBe(false);
});
it('assigns severity from the combined ActionDetail', () => {
const unitaryPins = makeUnitaryPins();
const actions: Record<string, ActionDetail> = {
'disco_LINE_A': makeAction({ max_rho: 0.5 }),
'reco_LINE_B': makeAction({ max_rho: 0.6 }),
'disco_LINE_A+reco_LINE_B': makeAction({ max_rho: 1.1 }),
};
const combined = buildCombinedActionPins(actions, unitaryPins, 0.95);
expect(combined[0].severity).toBe('red');
});
it('formats label as percentage from max_rho', () => {
const unitaryPins = makeUnitaryPins();
const actions: Record<string, ActionDetail> = {
'disco_LINE_A': makeAction({ max_rho: 0.5 }),
'reco_LINE_B': makeAction({ max_rho: 0.6 }),
'disco_LINE_A+reco_LINE_B': makeAction({ max_rho: 0.657 }),
};
const combined = buildCombinedActionPins(actions, unitaryPins, 0.95);
expect(combined[0].label).toBe('66%');
});
it('skips pairs where one constituent has no pin', () => {
const unitaryPins = makeUnitaryPins();
const actions: Record<string, ActionDetail> = {
'disco_LINE_A': makeAction({ max_rho: 0.5 }),
'reco_LINE_B': makeAction({ max_rho: 0.6 }),
'disco_LINE_A+unknown_action': makeAction({ max_rho: 0.4 }),
};
const combined = buildCombinedActionPins(actions, unitaryPins, 0.95);
expect(combined).toHaveLength(0);
});
it('stores constituent pin positions in p1 and p2', () => {
const unitaryPins = makeUnitaryPins();
const pin1 = unitaryPins.find(p => p.id === 'disco_LINE_A')!;
const pin2 = unitaryPins.find(p => p.id === 'reco_LINE_B')!;
const actions: Record<string, ActionDetail> = {
'disco_LINE_A': makeAction({ max_rho: 0.5 }),
'reco_LINE_B': makeAction({ max_rho: 0.6 }),
'disco_LINE_A+reco_LINE_B': makeAction({ max_rho: 0.4 }),
};
const combined = buildCombinedActionPins(actions, unitaryPins, 0.95);
expect(combined[0].p1).toEqual({ x: pin1.x, y: pin1.y });
expect(combined[0].p2).toEqual({ x: pin2.x, y: pin2.y });
});
});
describe('applyActionOverviewPins — selected/rejected status', () => {
const makeSvgContainer = () => {
const container = document.createElement('div');
container.innerHTML = '<svg viewBox="0 0 200 200"><g class="nad-vl-nodes"><circle r="30"/></g></svg>';
return container;
};
const pins = [
{ id: 'sel', x: 10, y: 10, severity: 'green' as const, label: '50%', title: 'Selected' },
{ id: 'rej', x: 50, y: 50, severity: 'red' as const, label: '110%', title: 'Rejected' },
{ id: 'neu', x: 90, y: 90, severity: 'orange' as const, label: '92%', title: 'Neutral' },
];
it('renders a gold star symbol above selected pins', () => {
const container = makeSvgContainer();
applyActionOverviewPins(container, pins, () => {}, undefined, {
selectedActionIds: new Set(['sel']),
});
const selPin = container.querySelector('g[data-action-id="sel"]');
expect(selPin).not.toBeNull();
// Star is a path inside the pin body (after teardrop path, circle, text)
const paths = selPin!.querySelectorAll('.nad-action-overview-pin-body path');
// First path = teardrop, second path = star
expect(paths.length).toBeGreaterThanOrEqual(2);
const starPath = paths[1];
expect(starPath.getAttribute('fill')).toBe('#eab308'); // gold
});
it('renders a red cross symbol above rejected pins', () => {
const container = makeSvgContainer();
applyActionOverviewPins(container, pins, () => {}, undefined, {
rejectedActionIds: new Set(['rej']),
});
const rejPin = container.querySelector('g[data-action-id="rej"]');
expect(rejPin).not.toBeNull();
const paths = rejPin!.querySelectorAll('.nad-action-overview-pin-body path');
expect(paths.length).toBeGreaterThanOrEqual(2);
const crossPath = paths[1];
expect(crossPath.getAttribute('fill')).toBe('#ef4444'); // red
});
it('dims rejected pins to 0.55 opacity', () => {
const container = makeSvgContainer();
applyActionOverviewPins(container, pins, () => {}, undefined, {
rejectedActionIds: new Set(['rej']),
});
const rejPin = container.querySelector('g[data-action-id="rej"]');
expect(rejPin!.getAttribute('opacity')).toBe('0.55');
});
it('uses highlighted fill for selected pins', () => {
const container = makeSvgContainer();
applyActionOverviewPins(container, pins, () => {}, undefined, {
selectedActionIds: new Set(['sel']),
});
const selPin = container.querySelector('g[data-action-id="sel"]');
const teardrop = selPin!.querySelector('.nad-action-overview-pin-body path');
// highlighted green = #1e9e3a (not standard #28a745)
expect(teardrop!.getAttribute('fill')).toBe('#1e9e3a');
});
it('uses dimmed fill for rejected pins', () => {
const container = makeSvgContainer();
applyActionOverviewPins(container, pins, () => {}, undefined, {
rejectedActionIds: new Set(['rej']),
});
const rejPin = container.querySelector('g[data-action-id="rej"]');
const teardrop = rejPin!.querySelector('.nad-action-overview-pin-body path');
// dimmed red = #d4a5ab (not standard #dc3545)
expect(teardrop!.getAttribute('fill')).toBe('#d4a5ab');
});
it('uses gold stroke on selected pin teardrop', () => {
const container = makeSvgContainer();
applyActionOverviewPins(container, pins, () => {}, undefined, {
selectedActionIds: new Set(['sel']),
});
const teardrop = container.querySelector('g[data-action-id="sel"] .nad-action-overview-pin-body path');
expect(teardrop!.getAttribute('stroke')).toBe('#eab308');
});
it('does not add symbols or modify fill for neutral pins', () => {
const container = makeSvgContainer();
applyActionOverviewPins(container, pins, () => {}, undefined, {
selectedActionIds: new Set(['sel']),
rejectedActionIds: new Set(['rej']),
});
const neuPin = container.querySelector('g[data-action-id="neu"]');
const paths = neuPin!.querySelectorAll('.nad-action-overview-pin-body path');
expect(paths.length).toBe(1); // only teardrop, no symbol
expect(paths[0].getAttribute('fill')).toBe('#f0ad4e'); // standard orange
expect(neuPin!.hasAttribute('opacity')).toBe(false);
});
});
describe('applyActionOverviewPins — combined action rendering', () => {
const makeSvgContainer = () => {
const container = document.createElement('div');
container.innerHTML =
'<svg viewBox="0 0 200 200">' +
' <g class="nad-vl-nodes"><circle r="30"/></g>' +
' <g class="nad-edge-paths"><path style="stroke-width: 5"/></g>' +
'</svg>';
return container;
};
const unitaryPins = [
{ id: 'a1', x: 10, y: 10, severity: 'green' as const, label: '50%', title: '' },
{ id: 'a2', x: 100, y: 100, severity: 'red' as const, label: '110%', title: '' },
];
it('renders a curved dashed path between constituent pins', () => {
const container = makeSvgContainer();
const combinedPins = [{
pairId: 'a1+a2', action1Id: 'a1', action2Id: 'a2',
p1: { x: 10, y: 10 }, p2: { x: 100, y: 100 },
x: 55, y: 55, label: '45%', title: 'combined', severity: 'green' as const,
}];
applyActionOverviewPins(container, unitaryPins, () => {}, undefined, { combinedPins });
const curves = container.querySelectorAll('.nad-combined-action-curve');
expect(curves.length).toBe(1);
const d = curves[0].getAttribute('d')!;
expect(d).toMatch(/^M 10 10 Q/); // starts at p1
expect(d).toMatch(/100 100$/); // ends at p2
});
it('uses edge stroke-width for curve thickness', () => {
const container = makeSvgContainer();
const combinedPins = [{
pairId: 'a1+a2', action1Id: 'a1', action2Id: 'a2',
p1: { x: 10, y: 10 }, p2: { x: 100, y: 100 },
x: 55, y: 55, label: '45%', title: '', severity: 'green' as const,
}];
applyActionOverviewPins(container, unitaryPins, () => {}, undefined, { combinedPins });
const curve = container.querySelector('.nad-combined-action-curve')!;
expect(curve.getAttribute('stroke-width')).toBe('5'); // matches edge path
});
it('renders a combined pin with "+" badge at the midpoint', () => {
const container = makeSvgContainer();
const combinedPins = [{
pairId: 'a1+a2', action1Id: 'a1', action2Id: 'a2',
p1: { x: 10, y: 10 }, p2: { x: 100, y: 100 },
x: 55, y: 55, label: '45%', title: '', severity: 'green' as const,
}];
applyActionOverviewPins(container, unitaryPins, () => {}, undefined, { combinedPins });
const combinedPin = container.querySelector('.nad-combined-action-pin');
expect(combinedPin).not.toBeNull();
expect(combinedPin!.getAttribute('data-action-id')).toBe('a1+a2');
// Check for "+" text badge
const texts = combinedPin!.querySelectorAll('text');
const plusText = Array.from(texts).find(t => t.textContent === '+');
expect(plusText).toBeDefined();
});
it('uses severity fill for curve stroke (not blue)', () => {
const container = makeSvgContainer();
const combinedPins = [{
pairId: 'a1+a2', action1Id: 'a1', action2Id: 'a2',
p1: { x: 10, y: 10 }, p2: { x: 100, y: 100 },
x: 55, y: 55, label: '45%', title: '', severity: 'green' as const,
}];
applyActionOverviewPins(container, unitaryPins, () => {}, undefined, { combinedPins });
const curve = container.querySelector('.nad-combined-action-curve')!;
expect(curve.getAttribute('stroke')).toBe('#28a745'); // green severity
});
it('uses severity fill for the combined pin teardrop', () => {
const container = makeSvgContainer();
const combinedPins = [{
pairId: 'a1+a2', action1Id: 'a1', action2Id: 'a2',
p1: { x: 10, y: 10 }, p2: { x: 100, y: 100 },
x: 55, y: 55, label: '45%', title: '', severity: 'red' as const,
}];
applyActionOverviewPins(container, unitaryPins, () => {}, undefined, { combinedPins });
const teardrop = container.querySelector('.nad-combined-action-pin .nad-action-overview-pin-body path');
expect(teardrop!.getAttribute('fill')).toBe('#dc3545'); // red severity
});
it('falls back to 3 SVG units for edge stroke when no edge path exists', () => {
const container = document.createElement('div');
container.innerHTML = '<svg viewBox="0 0 200 200"><g class="nad-vl-nodes"><circle r="30"/></g></svg>';
const combinedPins = [{
pairId: 'a1+a2', action1Id: 'a1', action2Id: 'a2',
p1: { x: 10, y: 10 }, p2: { x: 100, y: 100 },
x: 55, y: 55, label: '45%', title: '', severity: 'green' as const,
}];
applyActionOverviewPins(container, unitaryPins, () => {}, undefined, { combinedPins });
const curve = container.querySelector('.nad-combined-action-curve')!;
expect(curve.getAttribute('stroke-width')).toBe('3'); // fallback
});
});
describe('rescaleActionOverviewPins — does not rescale curves', () => {
it('does not modify curve stroke-width during rescale', () => {
const container = document.createElement('div');
container.innerHTML =
'<svg viewBox="0 0 1000 1000">' +
' <g class="nad-vl-nodes"><circle r="30"/></g>' +
' <g class="nad-edge-paths"><path style="stroke-width: 5"/></g>' +
'</svg>';
const combinedPins = [{
pairId: 'a+b', action1Id: 'a', action2Id: 'b',
p1: { x: 10, y: 10 }, p2: { x: 100, y: 100 },
x: 55, y: 55, label: '45%', title: '', severity: 'green' as const,
}];
applyActionOverviewPins(container, [
{ id: 'a', x: 10, y: 10, severity: 'green', label: '50%', title: '' },
{ id: 'b', x: 100, y: 100, severity: 'red', label: '110%', title: '' },
], () => {}, undefined, { combinedPins });
const curve = container.querySelector('.nad-combined-action-curve')!;
const initialStrokeW = curve.getAttribute('stroke-width');
// Simulate zoom-out by changing viewBox
const svg = container.querySelector('svg')!;
svg.setAttribute('viewBox', '0 0 5000 5000');
Object.defineProperty(container, 'clientWidth', { value: 500, configurable: true });
rescaleActionOverviewPins(container);
// Curve stroke-width must remain unchanged
expect(curve.getAttribute('stroke-width')).toBe(initialStrokeW);
});
});