Co-Study4Grid / frontend /src /components /ActionOverviewDiagram.test.tsx
github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
83 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, beforeEach, afterEach } from 'vitest';
import { render, fireEvent, cleanup, act } from '@testing-library/react';
import '@testing-library/jest-dom/vitest';
import ActionOverviewDiagram from './ActionOverviewDiagram';
import { interactionLogger } from '../utils/interactionLogger';
import type { ActionDetail, DiagramData, EdgeMeta, MetadataIndex, NodeMeta } from '../types';
// ------------------------------------------------------------
// Test fixtures
//
// A 4-node square with an extra isolated VL, mirrored from the
// svgUtils action-overview test fixture so the expected pin
// anchors and fit rectangles stay consistent between the two
// test suites.
// ------------------------------------------------------------
const makeMetaIndex = (): MetadataIndex => {
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' },
];
return {
nodesByEquipmentId: new Map(nodes.map(n => [n.equipmentId, n] as const)),
nodesBySvgId: new Map(nodes.map(n => [n.svgId, n] as const)),
edgesByEquipmentId: new Map(edges.map(e => [e.equipmentId, e] as const)),
edgesByNode: new Map(),
};
};
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,
});
// A minimal NAD-like SVG background — the test component
// parses this via innerHTML and queries for the injected pin
// layer. Includes:
// - `.nad-vl-nodes circle[r="40"]` so the pin sizing logic
// (which reads the VL circle radius) has something realistic
// to latch on
// - `<g id="svg-line-X">` stubs for every line referenced by
// the metadata index so `applyActionOverviewHighlights` can
// locate and clone the contingency / overload edges.
const makeN1Diagram = (): DiagramData => ({
svg:
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="-50 -50 700 700">' +
' <g class="nad-vl-nodes"><circle r="40"/></g>' +
' <g class="nad-edges">' +
' <g id="svg-line-a"><line x1="0" y1="0" x2="100" y2="0"/></g>' +
' <g id="svg-line-b"><line x1="100" y1="0" x2="100" y2="100"/></g>' +
' <g id="svg-line-c"><line x1="0" y1="0" x2="0" y2="100"/></g>' +
' <g id="svg-line-d"><line x1="0" y1="100" x2="100" y2="100"/></g>' +
' </g>' +
'</svg>',
metadata: null,
lines_overloaded: ['LINE_B'],
lines_overloaded_rho: [1.03],
});
const makeActions = (): 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.5, // green
max_rho_line: 'LINE_D',
}),
'coupling_VL_FAR': makeAction({
description_unitaire: "Ouverture du poste 'VL_FAR'",
max_rho: 1.1, // red
max_rho_line: 'LINE_B',
}),
});
const defaultProps = () => ({
n1Diagram: makeN1Diagram(),
n1MetaIndex: makeMetaIndex(),
actions: makeActions(),
monitoringFactor: 0.95,
onActionSelect: vi.fn(),
contingency: 'LINE_C' as string | null,
overloadedLines: ['LINE_B'] as readonly string[],
inspectableItems: ['LINE_A', 'LINE_B', 'LINE_C', 'LINE_D', 'VL_N1', 'VL_FAR'],
visible: true,
});
describe('ActionOverviewDiagram', () => {
afterEach(() => {
cleanup();
});
it('injects the N-1 SVG as background into its own container', () => {
const { container } = render(<ActionOverviewDiagram {...defaultProps()} />);
// The overview SVG (from innerHTML injection) should live
// inside the nad-action-overview-container div.
const host = container.querySelector('.nad-action-overview-container');
expect(host).not.toBeNull();
const svg = host!.querySelector('svg');
expect(svg).not.toBeNull();
// width/height are normalised to fill the container
expect(svg!.getAttribute('width')).toBe('100%');
expect(svg!.getAttribute('height')).toBe('100%');
expect(svg!.getAttribute('preserveAspectRatio')).toBe('xMidYMid meet');
});
it('marks the SVG with nad-overview-dimmed class and inserts a dim rect overlay', () => {
const { container } = render(<ActionOverviewDiagram {...defaultProps()} />);
const host = container.querySelector('.nad-action-overview-container');
const svg = host!.querySelector('svg');
expect(svg!.classList.contains('nad-overview-dimmed')).toBe(true);
expect(svg!.querySelector('rect.nad-overview-dim-rect')).not.toBeNull();
// Original SVG content stays at the SVG root (no wrapper group).
expect(svg!.querySelector('.nad-edges')).not.toBeNull();
expect(svg!.querySelector('.nad-vl-nodes')).not.toBeNull();
});
it('clears the container when n1Diagram becomes null', () => {
const props = defaultProps();
const { container, rerender } = render(<ActionOverviewDiagram {...props} />);
const host = container.querySelector('.nad-action-overview-container')!;
expect(host.querySelector('svg')).not.toBeNull();
rerender(<ActionOverviewDiagram {...props} n1Diagram={null as unknown as DiagramData} />);
expect(host.querySelector('svg')).toBeNull();
});
it('renders one pin per simulated action using the severity palette', () => {
const { container } = render(<ActionOverviewDiagram {...defaultProps()} />);
const pins = container.querySelectorAll('g.nad-action-overview-pin');
expect(pins.length).toBe(2);
const fills = Array.from(pins).map(p => p.querySelector('path')?.getAttribute('fill'));
// disco_LINE_A: rho=0.5 → green; coupling_VL_FAR: rho=1.1 → red
expect(fills).toEqual(expect.arrayContaining(['#28a745', '#dc3545']));
});
it('draws pins WITHOUT a stroke outline', () => {
const { container } = render(<ActionOverviewDiagram {...defaultProps()} />);
// path lives inside the inner .nad-action-overview-pin-body
// wrapper so the rescaler can upscale it on unzoom.
const paths = container.querySelectorAll('g.nad-action-overview-pin path');
expect(paths.length).toBeGreaterThan(0);
paths.forEach(p => {
expect(p.getAttribute('stroke')).toBe('none');
expect(p.hasAttribute('stroke-width')).toBe(false);
});
});
it('each pin displays the rounded max loading as its label', () => {
const { container } = render(<ActionOverviewDiagram {...defaultProps()} />);
const texts = Array.from(container.querySelectorAll('g.nad-action-overview-pin text'))
.map(t => t.textContent);
// 0.5 → "50%", 1.1 → "110%"
expect(texts).toEqual(expect.arrayContaining(['50%', '110%']));
});
it('pin anchors on the asset highlighted by the corresponding card', () => {
const { container } = render(<ActionOverviewDiagram {...defaultProps()} />);
const groups = Array.from(container.querySelectorAll('g.nad-action-overview-pin'));
const byId = Object.fromEntries(groups.map(g => [
g.getAttribute('data-action-id'),
g.getAttribute('transform'),
]));
// disco_LINE_A anchors on LINE_A midpoint (50, 0)
expect(byId['disco_LINE_A']).toBe('translate(50 0)');
// coupling_VL_FAR anchors on the VL node (500, 500)
expect(byId['coupling_VL_FAR']).toBe('translate(500 500)');
});
it('double-clicking a pin invokes onActionSelect with its id (drill-down path)', () => {
// Semantics changed in the click-vs-dblclick split:
// - single-click → opens the popover (separate test below)
// - double-click → selects the action (this test)
const onActionSelect = vi.fn();
const { container } = render(
<ActionOverviewDiagram {...defaultProps()} onActionSelect={onActionSelect} />,
);
const pin = container.querySelector('g.nad-action-overview-pin[data-action-id="disco_LINE_A"]');
expect(pin).not.toBeNull();
fireEvent.doubleClick(pin!);
expect(onActionSelect).toHaveBeenCalledWith('disco_LINE_A');
});
it('auto-zooms to the fit rectangle on mount', () => {
const { container } = render(<ActionOverviewDiagram {...defaultProps()} />);
// Fit rect unions:
// contingency LINE_C: (0,0)..(0,100)
// overload LINE_B: (100,0)..(100,100)
// pins: (50, 0), (500, 500)
// Raw bbox = [0..500, 0..500] → 5% pad → [-25..525, -25..525]
// So w=550, h=550, x=-25, y=-25.
const svg = container.querySelector('.nad-action-overview-container svg');
const vb = svg!.getAttribute('viewBox')!;
const parts = vb.split(/\s+/).map(parseFloat);
expect(parts[0]).toBeCloseTo(-25, 0);
expect(parts[1]).toBeCloseTo(-25, 0);
expect(parts[2]).toBeCloseTo(550, 0);
expect(parts[3]).toBeCloseTo(550, 0);
});
it('no longer renders the severity / action-type filter controls (driven by the sidebar rings)', () => {
const { queryByTestId } = render(<ActionOverviewDiagram {...defaultProps()} />);
// Severity (action-card colour) toggles + bulk buttons removed —
// they are redundant with the sidebar's ActionFilterRings.
for (const c of ['green', 'orange', 'red', 'grey']) {
expect(queryByTestId(`filter-category-${c}`)).not.toBeInTheDocument();
}
expect(queryByTestId('filter-select-all')).not.toBeInTheDocument();
expect(queryByTestId('filter-select-none')).not.toBeInTheDocument();
// Action-type chip row removed.
expect(queryByTestId('overview-action-type-filter')).not.toBeInTheDocument();
// Threshold moved to the sidebar's ActionFilterRings (compact
// single-row layout). The non-redundant remaining filters stay
// in the overview header.
expect(queryByTestId('filter-threshold')).not.toBeInTheDocument();
expect(queryByTestId('filter-threshold-input')).not.toBeInTheDocument();
expect(queryByTestId('filter-show-unsimulated')).toBeInTheDocument();
expect(queryByTestId('filter-combined-only')).toBeInTheDocument();
});
it('shows the pin count in the header', () => {
const { getByTestId } = render(<ActionOverviewDiagram {...defaultProps()} />);
// The counter is now a compact 📍<n> chip — the full
// "on the N-1 network" phrasing moved to the title tooltip.
const counter = getByTestId('overview-pin-counter');
expect(counter.textContent).toContain('2');
expect(counter.getAttribute('title')).toContain('2 pins on the N-1 network');
});
it('hides itself when visible=false', () => {
const { container } = render(
<ActionOverviewDiagram {...defaultProps()} visible={false} />,
);
const root = container.querySelector('[data-testid="action-overview-diagram"]') as HTMLElement;
expect(root.style.display).toBe('none');
});
it('shows the "no analysis yet" empty state when there are no actions', () => {
const { getByText } = render(
<ActionOverviewDiagram {...defaultProps()} actions={{}} />,
);
expect(getByText(/Run.*Analyze.*Suggest/)).toBeInTheDocument();
});
it('shows the "no background" empty state when no N-1 diagram is loaded', () => {
const { getByText } = render(
<ActionOverviewDiagram {...defaultProps()} n1Diagram={null} />,
);
expect(getByText(/Load a contingency first/)).toBeInTheDocument();
});
describe('local zoom controls', () => {
it('clicking "+" shrinks the viewBox around its centre', () => {
const { container, getByTitle } = render(<ActionOverviewDiagram {...defaultProps()} />);
const svg = container.querySelector('.nad-action-overview-container svg')!;
const before = svg.getAttribute('viewBox')!.split(/\s+/).map(parseFloat);
fireEvent.click(getByTitle('Zoom In'));
const after = svg.getAttribute('viewBox')!.split(/\s+/).map(parseFloat);
// Width shrinks by 0.8×
expect(after[2]).toBeCloseTo(before[2] * 0.8, 1);
expect(after[3]).toBeCloseTo(before[3] * 0.8, 1);
});
it('clicking "-" grows the viewBox around its centre', () => {
const { container, getByTitle } = render(<ActionOverviewDiagram {...defaultProps()} />);
const svg = container.querySelector('.nad-action-overview-container svg')!;
const before = svg.getAttribute('viewBox')!.split(/\s+/).map(parseFloat);
fireEvent.click(getByTitle('Zoom Out'));
const after = svg.getAttribute('viewBox')!.split(/\s+/).map(parseFloat);
expect(after[2]).toBeCloseTo(before[2] * 1.25, 1);
expect(after[3]).toBeCloseTo(before[3] * 1.25, 1);
});
it('Fit button restores the auto-fit rectangle after panning', () => {
const { container, getByTitle } = render(<ActionOverviewDiagram {...defaultProps()} />);
const svg = container.querySelector('.nad-action-overview-container svg')!;
const fit = svg.getAttribute('viewBox')!;
fireEvent.click(getByTitle('Zoom In'));
expect(svg.getAttribute('viewBox')).not.toBe(fit);
fireEvent.click(getByTitle(/Reset view/i));
expect(svg.getAttribute('viewBox')).toBe(fit);
});
});
describe('asset-focus inspect search', () => {
beforeEach(() => {
// `usePanZoom` debounces the React commit through setTimeout;
// use fake timers so the tests can deterministically flush
// any pending commits.
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('typing an exact equipment id zooms onto it', () => {
const { container, getByTestId } = render(<ActionOverviewDiagram {...defaultProps()} />);
const svg = container.querySelector('.nad-action-overview-container svg')!;
const fitVb = svg.getAttribute('viewBox')!;
const input = getByTestId('overview-inspect-input') as HTMLInputElement;
fireEvent.change(input, { target: { value: 'VL_FAR' } });
const focused = svg.getAttribute('viewBox')!;
expect(focused).not.toBe(fitVb);
// VL_FAR is a single-point → expanded to 150x150 around (500, 500)
const parts = focused.split(/\s+/).map(parseFloat);
expect(parts[0]).toBeCloseTo(500 - 75 - 150 * 0.35, 0);
expect(parts[1]).toBeCloseTo(500 - 75 - 150 * 0.35, 0);
});
it('does NOT re-focus on the same asset after the user zooms out (regression)', () => {
// This is the "sticking" bug: after focusing on an asset,
// unzooming used to re-run the effect and snap back onto
// the asset. The fix is `lastFocusedRef` — here we assert
// that a subsequent zoom-out is preserved.
const { container, getByTitle, getByTestId } = render(
<ActionOverviewDiagram {...defaultProps()} />,
);
const svg = container.querySelector('.nad-action-overview-container svg')!;
const input = getByTestId('overview-inspect-input') as HTMLInputElement;
// Focus on VL_FAR
fireEvent.change(input, { target: { value: 'VL_FAR' } });
const focusedVb = svg.getAttribute('viewBox')!;
// Zoom out — this changes pz.viewBox and therefore the
// memoised pz object identity, which previously caused
// the inspect effect to re-fire and snap back onto VL_FAR.
fireEvent.click(getByTitle('Zoom Out'));
// Flush any pending rAF / setTimeout commits
act(() => { vi.runAllTimers(); });
const afterZoomOut = svg.getAttribute('viewBox')!;
expect(afterZoomOut).not.toBe(focusedVb);
// One more zoom-out: the view should continue to widen,
// NOT snap back onto the asset.
const afterPrevZoom = svg.getAttribute('viewBox')!;
fireEvent.click(getByTitle('Zoom Out'));
act(() => { vi.runAllTimers(); });
const afterZoomOut2 = svg.getAttribute('viewBox')!;
expect(afterZoomOut2).not.toBe(afterPrevZoom);
expect(afterZoomOut2).not.toBe(focusedVb);
});
it('clearing the inspect query returns the view to the auto-fit rectangle', () => {
const { container, getByTestId } = render(<ActionOverviewDiagram {...defaultProps()} />);
const svg = container.querySelector('.nad-action-overview-container svg')!;
const fitVb = svg.getAttribute('viewBox')!;
const input = getByTestId('overview-inspect-input') as HTMLInputElement;
fireEvent.change(input, { target: { value: 'VL_FAR' } });
expect(svg.getAttribute('viewBox')).not.toBe(fitVb);
fireEvent.change(input, { target: { value: '' } });
expect(svg.getAttribute('viewBox')).toBe(fitVb);
});
it('Fit button re-enables focusing on the same asset again', () => {
// After a Fit reset, typing the same asset id should
// focus again — the reset clears `lastFocusedRef` so the
// consume-once guard no longer swallows the re-entry.
const { container, getByTitle, getByTestId } = render(
<ActionOverviewDiagram {...defaultProps()} />,
);
const svg = container.querySelector('.nad-action-overview-container svg')!;
const input = getByTestId('overview-inspect-input') as HTMLInputElement;
fireEvent.change(input, { target: { value: 'VL_FAR' } });
const focusedVb1 = svg.getAttribute('viewBox')!;
// Back to fit
fireEvent.click(getByTitle(/Reset view/i));
fireEvent.change(input, { target: { value: '' } });
// Re-type the same asset id — should focus again, NOT
// silently skip because the ref remembered it.
fireEvent.change(input, { target: { value: 'VL_FAR' } });
const focusedVb2 = svg.getAttribute('viewBox')!;
expect(focusedVb2).toBe(focusedVb1);
});
});
describe('pin sizing and screen-constant rescaling', () => {
it('sizes pins from the VL circle radius (40 in the fixture)', () => {
const { container } = render(<ActionOverviewDiagram {...defaultProps()} />);
// The teardrop path uses the VL circle radius R=40
// from makeN1Diagram(), so the arc command must
// reference "A 40 40" in its `d` attribute.
const path = container.querySelector('g.nad-action-overview-pin path');
expect(path).not.toBeNull();
expect(path!.getAttribute('d')).toContain('A 40 40');
});
it('wraps every pin glyph in a rescalable .nad-action-overview-pin-body', () => {
const { container } = render(<ActionOverviewDiagram {...defaultProps()} />);
const bodies = container.querySelectorAll('g.nad-action-overview-pin-body');
expect(bodies.length).toBe(2);
// Outer translate stays clean, body carries the scale.
bodies.forEach(b => {
expect(b.getAttribute('transform')).toMatch(/^scale\(/);
});
});
it('rescales pins when the svg viewBox attribute changes', () => {
// MutationObserver on the svg's viewBox attribute is
// what keeps pins screen-constant during wheel/drag.
// Directly mutating viewBox here simulates usePanZoom's
// fast-path DOM writes and verifies the rescaler reacts.
const { container } = render(<ActionOverviewDiagram {...defaultProps()} />);
const svg = container.querySelector('.nad-action-overview-container svg') as unknown as SVGGraphicsElement;
const svgContainer = container.querySelector('.nad-action-overview-container')!;
// Mock clientWidth so the viewBox-based pxPerSvgUnit
// calculation yields 0.1 (clientWidth / viewBoxWidth).
// viewBox will be "0 0 99999 99999", so clientWidth = 9999.9
Object.defineProperty(svgContainer, 'clientWidth', { value: 9999.9, configurable: true });
// Mutate the viewBox — the MutationObserver schedules
// a rAF, so we wait one animation frame to read the
// committed scale.
(svg as unknown as SVGSVGElement).setAttribute('viewBox', '0 0 99999 99999');
return new Promise<void>(resolve => {
// First a microtask flush to let MO deliver its records,
// then a rAF to let the throttled rescaler run.
queueMicrotask(() => {
requestAnimationFrame(() => {
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]);
// pxPerSvgUnit=0.1, MIN_SCREEN_RADIUS_PX=22,
// baseR=40, viewBoxMinR=99999/50≈2000
// effectiveR=max(40, 220, 2000)=2000 → scale≈50
expect(scale).toBeCloseTo(99999 / 50 / 40, 1);
resolve();
});
});
});
});
it('does not upscale pins when VL circles are already big enough on screen', () => {
const { container } = render(<ActionOverviewDiagram {...defaultProps()} />);
const svgContainer = container.querySelector('.nad-action-overview-container')!;
const svg = svgContainer.querySelector('svg') as unknown as SVGGraphicsElement;
// viewBox "0 0 100 100", clientWidth=200 → pxPerSvgUnit=2
// → VL circle with r=40 = 80 screen px, well above the 22-px floor.
Object.defineProperty(svgContainer, 'clientWidth', { value: 200, configurable: true });
(svg as unknown as SVGSVGElement).setAttribute('viewBox', '0 0 100 100');
return new Promise<void>(resolve => {
queueMicrotask(() => {
requestAnimationFrame(() => {
const body = container.querySelector('g.nad-action-overview-pin-body')!;
expect(body.getAttribute('transform')).toBe('scale(1)');
resolve();
});
});
});
});
it('rAF-throttles rescale: many viewBox mutations in a row trigger AT MOST one rescale per animation frame', () => {
// Regression guard for the "Page ne répondant pas" lag:
// the wheel-zoom path in usePanZoom mutates the viewBox
// many times per frame; without rAF batching that would
// call getScreenCTM (a forced layout) on every mutation
// and freeze the page on large grids.
const { container } = render(<ActionOverviewDiagram {...defaultProps()} />);
const svg = container.querySelector('.nad-action-overview-container svg') as unknown as SVGGraphicsElement;
let ctmCalls = 0;
svg.getScreenCTM = (() => {
ctmCalls += 1;
return { a: 0.5, b: 0, c: 0, d: 0.5, e: 0, f: 0 };
}) as unknown as SVGGraphicsElement['getScreenCTM'];
// Reset the counter — initial pin mount + dim wrap may
// have invoked getScreenCTM already; we only care about
// the burst that follows the rapid viewBox writes.
ctmCalls = 0;
// Simulate a wheel-zoom burst: 20 viewBox writes in a row.
for (let i = 1; i <= 20; i++) {
(svg as unknown as SVGSVGElement).setAttribute('viewBox', `0 0 ${1000 + i} ${1000 + i}`);
}
return new Promise<void>(resolve => {
queueMicrotask(() => {
requestAnimationFrame(() => {
// Even though we wrote viewBox 20 times, the
// throttled rescaler should have run AT MOST
// once for that frame (and called getScreenCTM
// exactly once inside that single rescale).
expect(ctmCalls).toBeLessThanOrEqual(1);
resolve();
});
});
});
});
});
describe('contingency + overload highlights', () => {
it('renders contingency + overload highlight clones via applyActionOverviewHighlights', () => {
// The fixture sets contingency='LINE_C' and overloadedLines=['LINE_B'].
// Both must produce a clone inside the .nad-overview-highlight-layer.
const { container } = render(<ActionOverviewDiagram {...defaultProps()} />);
const layer = container.querySelector('.nad-overview-highlight-layer');
expect(layer).not.toBeNull();
expect(layer!.querySelector('.nad-contingency-highlight')).not.toBeNull();
expect(layer!.querySelector('.nad-overloaded')).not.toBeNull();
});
it('highlight and pin layers are direct children of the SVG', () => {
const { container } = render(<ActionOverviewDiagram {...defaultProps()} />);
const svg = container.querySelector('.nad-action-overview-container svg')!;
const highlightLayer = svg.querySelector(':scope > g.nad-overview-highlight-layer');
const pinLayer = svg.querySelector(':scope > g.nad-action-overview-pins');
expect(highlightLayer).not.toBeNull();
expect(pinLayer).not.toBeNull();
});
it('places highlight layer BEFORE NAD content (at SVG start) and BEFORE dim rect and pins', () => {
const { container } = render(<ActionOverviewDiagram {...defaultProps()} />);
const svg = container.querySelector('.nad-action-overview-container svg')!;
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'));
expect(highlightIdx).toBeGreaterThan(-1);
expect(dimIdx).toBeGreaterThan(-1);
expect(pinIdx).toBeGreaterThan(-1);
// Highlights at start, before everything else
expect(highlightIdx).toBeLessThan(dimIdx);
expect(highlightIdx).toBeLessThan(pinIdx);
});
it('refreshes highlights when contingency changes', () => {
const { container, rerender } = render(<ActionOverviewDiagram {...defaultProps()} />);
const layer1 = container.querySelector('.nad-overview-highlight-layer');
expect(layer1).not.toBeNull();
// Change contingency to a non-resolvable id — the
// highlight layer should be wiped (1 contingency lost),
// leaving only the overload clone.
rerender(<ActionOverviewDiagram {...defaultProps()} contingency={null} />);
const layer2 = container.querySelector('.nad-overview-highlight-layer');
expect(layer2).not.toBeNull();
expect(layer2!.querySelector('.nad-contingency-highlight')).toBeNull();
expect(layer2!.querySelector('.nad-overloaded')).not.toBeNull();
});
});
describe('dim background (rect overlay, no opacity group or per-child opacity)', () => {
it('inserts a white dim rect overlay instead of wrapping in an opacity group or using CSS per-child opacity', () => {
// The old <g opacity="0.35"> wrapper created an SVG
// transparency group (Layerize: 31s). CSS per-child
// opacity still created stacking contexts (25s). Now we
// use a single white <rect> overlay — zero stacking contexts.
const { container } = render(<ActionOverviewDiagram {...defaultProps()} />);
const svg = container.querySelector('.nad-action-overview-container svg')!;
const dimRect = svg.querySelector('rect.nad-overview-dim-rect');
expect(dimRect).not.toBeNull();
expect(dimRect!.getAttribute('fill')).toBe('white');
expect(dimRect!.getAttribute('opacity')).toBe('0.65');
expect(dimRect!.getAttribute('pointer-events')).toBe('none');
// No <g> opacity wrapper should exist.
expect(svg.querySelector('g.nad-overview-dim-layer')).toBeNull();
});
it('dim rect covers the viewBox with margin', () => {
const { container } = render(<ActionOverviewDiagram {...defaultProps()} />);
const svg = container.querySelector('.nad-action-overview-container svg')!;
const dimRect = svg.querySelector('rect.nad-overview-dim-rect')!;
// viewBox="-50 -50 700 700", margin = 700 * 0.1 = 70
const x = parseFloat(dimRect.getAttribute('x')!);
const y = parseFloat(dimRect.getAttribute('y')!);
const w = parseFloat(dimRect.getAttribute('width')!);
const h = parseFloat(dimRect.getAttribute('height')!);
expect(x).toBeCloseTo(-50 - 70, 0);
expect(y).toBeCloseTo(-50 - 70, 0);
expect(w).toBeCloseTo(700 + 140, 0);
expect(h).toBeCloseTo(700 + 140, 0);
});
it('pin layer is a direct child of the SVG (not nested)', () => {
const { container } = render(<ActionOverviewDiagram {...defaultProps()} />);
const svg = container.querySelector('.nad-action-overview-container svg')!;
const pinLayer = svg.querySelector(':scope > g.nad-action-overview-pins');
expect(pinLayer).not.toBeNull();
});
it('visual stack order: highlights → NAD content → dim rect → pins', () => {
const { container } = render(<ActionOverviewDiagram {...defaultProps()} />);
const svg = container.querySelector('.nad-action-overview-container svg')!;
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'));
expect(highlightIdx).toBeGreaterThan(-1);
expect(dimIdx).toBeGreaterThan(-1);
expect(pinIdx).toBeGreaterThan(-1);
// Highlights first (behind NAD), dim rect after NAD, pins last
expect(highlightIdx).toBeLessThan(dimIdx);
expect(dimIdx).toBeLessThan(pinIdx);
});
});
describe('click popover (single-click on pin)', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('opens an ActionCard popover on single-click (after the single-click delay)', async () => {
const { container, queryByTestId } = render(<ActionOverviewDiagram {...defaultProps()} />);
// Before clicking, no popover is mounted.
expect(queryByTestId('action-overview-popover')).toBeNull();
const pin = container.querySelector('g.nad-action-overview-pin[data-action-id="disco_LINE_A"]');
fireEvent.click(pin!);
// The click action is deferred — popover is NOT up yet.
expect(queryByTestId('action-overview-popover')).toBeNull();
// Advance the single-click delay + flush React batching.
act(() => { vi.advanceTimersByTime(300); });
const popover = queryByTestId('action-overview-popover');
expect(popover).not.toBeNull();
expect(popover!.getAttribute('data-action-id')).toBe('disco_LINE_A');
// The popover embeds the real ActionCard for this id.
expect(popover!.querySelector('[data-testid="action-card-disco_LINE_A"]')).not.toBeNull();
});
it('does NOT open the popover when the user double-clicks the same pin', async () => {
const onActionSelect = vi.fn();
const { container, queryByTestId } = render(
<ActionOverviewDiagram {...defaultProps()} onActionSelect={onActionSelect} />,
);
const pin = container.querySelector('g.nad-action-overview-pin[data-action-id="disco_LINE_A"]');
// Simulate the real browser sequence: click, click, dblclick.
fireEvent.click(pin!);
fireEvent.click(pin!);
fireEvent.doubleClick(pin!);
act(() => { vi.advanceTimersByTime(500); });
expect(queryByTestId('action-overview-popover')).toBeNull();
expect(onActionSelect).toHaveBeenCalledWith('disco_LINE_A');
});
it('closes the popover when the ✕ button is clicked', async () => {
const { container, getByTestId, queryByTestId } = render(
<ActionOverviewDiagram {...defaultProps()} />,
);
const pin = container.querySelector('g.nad-action-overview-pin[data-action-id="disco_LINE_A"]');
fireEvent.click(pin!);
act(() => { vi.advanceTimersByTime(300); });
expect(queryByTestId('action-overview-popover')).not.toBeNull();
fireEvent.click(getByTestId('action-overview-popover-close'));
expect(queryByTestId('action-overview-popover')).toBeNull();
});
it('closes the popover when Escape is pressed', async () => {
const { container, queryByTestId } = render(<ActionOverviewDiagram {...defaultProps()} />);
const pin = container.querySelector('g.nad-action-overview-pin[data-action-id="disco_LINE_A"]');
fireEvent.click(pin!);
act(() => { vi.advanceTimersByTime(300); });
expect(queryByTestId('action-overview-popover')).not.toBeNull();
act(() => {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
});
expect(queryByTestId('action-overview-popover')).toBeNull();
});
it('clicking inside the popover does NOT dismiss it (stopPropagation)', async () => {
const { container, getByTestId, queryByTestId } = render(
<ActionOverviewDiagram {...defaultProps()} />,
);
const pin = container.querySelector('g.nad-action-overview-pin[data-action-id="disco_LINE_A"]');
fireEvent.click(pin!);
act(() => { vi.advanceTimersByTime(300); });
const popover = getByTestId('action-overview-popover');
fireEvent.mouseDown(popover);
expect(queryByTestId('action-overview-popover')).not.toBeNull();
});
it('clicking the ActionCard body inside the popover activates the drill-down and closes the popover', async () => {
const onActionSelect = vi.fn();
const { container, getByTestId, queryByTestId } = render(
<ActionOverviewDiagram {...defaultProps()} onActionSelect={onActionSelect} />,
);
const pin = container.querySelector('g.nad-action-overview-pin[data-action-id="disco_LINE_A"]');
fireEvent.click(pin!);
act(() => { vi.advanceTimersByTime(300); });
const card = getByTestId('action-card-disco_LINE_A');
fireEvent.click(card);
expect(onActionSelect).toHaveBeenCalledWith('disco_LINE_A');
// Popover should have been cleared as part of the activation flow.
expect(queryByTestId('action-overview-popover')).toBeNull();
});
it('does not render the popover when the overview is hidden', async () => {
const { container, rerender, queryByTestId } = render(
<ActionOverviewDiagram {...defaultProps()} />,
);
const pin = container.querySelector('g.nad-action-overview-pin[data-action-id="disco_LINE_A"]');
fireEvent.click(pin!);
act(() => { vi.advanceTimersByTime(300); });
expect(queryByTestId('action-overview-popover')).not.toBeNull();
rerender(<ActionOverviewDiagram {...defaultProps()} visible={false} />);
expect(queryByTestId('action-overview-popover')).toBeNull();
});
it('records data-place-above and data-horizontal-align attributes from the placement helper', async () => {
// Stub getBoundingClientRect on the pin so the click
// handler captures a known screen position. We pin
// it to the bottom-right corner of the viewport
// (1024x768 in jsdom by default) so the placement
// helper picks above + end.
const { container, getByTestId } = render(<ActionOverviewDiagram {...defaultProps()} />);
const pin = container.querySelector('g.nad-action-overview-pin[data-action-id="disco_LINE_A"]') as SVGGElement;
pin.getBoundingClientRect = (() => ({
left: 900, right: 920,
top: 700, bottom: 720,
width: 20, height: 20,
x: 900, y: 700,
toJSON: () => ({}),
})) as unknown as SVGGElement['getBoundingClientRect'];
fireEvent.click(pin);
act(() => { vi.advanceTimersByTime(300); });
const popover = getByTestId('action-overview-popover');
expect(popover.getAttribute('data-place-above')).toBe('true');
expect(popover.getAttribute('data-horizontal-align')).toBe('end');
// Above placement → uses CSS `bottom`, not `top`.
expect(popover.style.bottom).not.toBe('');
expect(popover.style.top).toBe('');
});
it('renders the popover BELOW the pin when the pin is in the top of the viewport', async () => {
const { container, getByTestId } = render(<ActionOverviewDiagram {...defaultProps()} />);
const pin = container.querySelector('g.nad-action-overview-pin[data-action-id="disco_LINE_A"]') as SVGGElement;
pin.getBoundingClientRect = (() => ({
left: 100, right: 120,
top: 50, bottom: 70,
width: 20, height: 20,
x: 100, y: 50,
toJSON: () => ({}),
})) as unknown as SVGGElement['getBoundingClientRect'];
fireEvent.click(pin);
act(() => { vi.advanceTimersByTime(300); });
const popover = getByTestId('action-overview-popover');
expect(popover.getAttribute('data-place-above')).toBe('false');
expect(popover.getAttribute('data-horizontal-align')).toBe('start');
expect(popover.style.top).not.toBe('');
expect(popover.style.bottom).toBe('');
});
it('calls onPinPreview with the action id on single-click', () => {
const onPinPreview = vi.fn();
const { container } = render(
<ActionOverviewDiagram {...defaultProps()} onPinPreview={onPinPreview} />,
);
const pin = container.querySelector('g.nad-action-overview-pin[data-action-id="disco_LINE_A"]');
fireEvent.click(pin!);
act(() => { vi.advanceTimersByTime(300); });
expect(onPinPreview).toHaveBeenCalledWith('disco_LINE_A');
});
it('does NOT call onPinPreview on double-click (drill-down)', () => {
const onPinPreview = vi.fn();
const { container } = render(
<ActionOverviewDiagram {...defaultProps()} onPinPreview={onPinPreview} />,
);
const pin = container.querySelector('g.nad-action-overview-pin[data-action-id="disco_LINE_A"]');
fireEvent.click(pin!);
fireEvent.click(pin!);
fireEvent.doubleClick(pin!);
act(() => { vi.advanceTimersByTime(300); });
// Double-click cancels the single-click timer, so onPinPreview
// should NOT have been called.
expect(onPinPreview).not.toHaveBeenCalled();
});
});
describe('interaction logging', () => {
beforeEach(() => {
vi.useFakeTimers();
interactionLogger.clear();
});
afterEach(() => {
vi.useRealTimers();
});
const lastLog = (type: string) =>
interactionLogger.getLog().filter(e => e.type === type).pop();
it('logs overview_shown when the component becomes visible', () => {
render(<ActionOverviewDiagram {...defaultProps()} />);
expect(lastLog('overview_shown')).toBeDefined();
expect(lastLog('overview_shown')!.details).toHaveProperty('pin_count');
});
it('logs overview_hidden when visible flips false', () => {
const { rerender } = render(<ActionOverviewDiagram {...defaultProps()} />);
rerender(<ActionOverviewDiagram {...defaultProps()} visible={false} />);
expect(lastLog('overview_hidden')).toBeDefined();
});
it('logs overview_pin_clicked on single-click (after the delay)', () => {
const { container } = render(<ActionOverviewDiagram {...defaultProps()} />);
const pin = container.querySelector('g.nad-action-overview-pin[data-action-id="disco_LINE_A"]');
fireEvent.click(pin!);
act(() => { vi.advanceTimersByTime(300); });
const entry = lastLog('overview_pin_clicked');
expect(entry).toBeDefined();
expect(entry!.details.action_id).toBe('disco_LINE_A');
});
it('logs overview_pin_double_clicked on double-click', () => {
const { container } = render(<ActionOverviewDiagram {...defaultProps()} />);
const pin = container.querySelector('g.nad-action-overview-pin[data-action-id="disco_LINE_A"]');
fireEvent.click(pin!);
fireEvent.click(pin!);
fireEvent.doubleClick(pin!);
act(() => { vi.advanceTimersByTime(300); });
const entry = lastLog('overview_pin_double_clicked');
expect(entry).toBeDefined();
expect(entry!.details.action_id).toBe('disco_LINE_A');
// The single-click should NOT have been logged (cancelled by dblclick)
expect(lastLog('overview_pin_clicked')).toBeUndefined();
});
it('logs overview_popover_closed with reason on close button', () => {
const { container, getByTestId } = render(<ActionOverviewDiagram {...defaultProps()} />);
const pin = container.querySelector('g.nad-action-overview-pin[data-action-id="disco_LINE_A"]');
fireEvent.click(pin!);
act(() => { vi.advanceTimersByTime(300); });
fireEvent.click(getByTestId('action-overview-popover-close'));
const entry = lastLog('overview_popover_closed');
expect(entry).toBeDefined();
expect(entry!.details.reason).toBe('close_button');
});
it('logs overview_popover_closed with reason "escape" on Escape key', () => {
const { container } = render(<ActionOverviewDiagram {...defaultProps()} />);
const pin = container.querySelector('g.nad-action-overview-pin[data-action-id="disco_LINE_A"]');
fireEvent.click(pin!);
act(() => { vi.advanceTimersByTime(300); });
act(() => { document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); });
expect(lastLog('overview_popover_closed')!.details.reason).toBe('escape');
});
it('logs overview_zoom_in / overview_zoom_out / overview_zoom_fit on button clicks', () => {
const { getByTitle } = render(<ActionOverviewDiagram {...defaultProps()} />);
fireEvent.click(getByTitle('Zoom In'));
expect(lastLog('overview_zoom_in')).toBeDefined();
fireEvent.click(getByTitle('Zoom Out'));
expect(lastLog('overview_zoom_out')).toBeDefined();
fireEvent.click(getByTitle(/Reset view/i));
expect(lastLog('overview_zoom_fit')).toBeDefined();
});
it('logs overview_inspect_changed when an exact equipment match is typed', () => {
const { getByTestId } = render(<ActionOverviewDiagram {...defaultProps()} />);
const input = getByTestId('overview-inspect-input') as HTMLInputElement;
fireEvent.change(input, { target: { value: 'VL_FAR' } });
const entry = lastLog('overview_inspect_changed');
expect(entry).toBeDefined();
expect(entry!.details).toEqual({ query: 'VL_FAR', action: 'focus' });
});
it('logs overview_inspect_changed with action "cleared" when query is cleared', () => {
const { getByTestId } = render(<ActionOverviewDiagram {...defaultProps()} />);
const input = getByTestId('overview-inspect-input') as HTMLInputElement;
fireEvent.change(input, { target: { value: 'VL_FAR' } });
fireEvent.change(input, { target: { value: '' } });
const entry = lastLog('overview_inspect_changed');
expect(entry!.details.action).toBe('cleared');
});
});
describe('filters', () => {
const allCategories = { green: true, orange: true, red: true, grey: true };
it('drops the threshold + category toggles + bulk buttons (severity / threshold rings now live in the sidebar)', () => {
const { getByTestId, queryByTestId } = render(
<ActionOverviewDiagram
{...defaultProps()}
filters={{ categories: allCategories, threshold: 1.5, showUnsimulated: false }}
/>,
);
expect(queryByTestId('filter-category-green')).not.toBeInTheDocument();
expect(queryByTestId('filter-category-grey')).not.toBeInTheDocument();
expect(queryByTestId('filter-select-all')).not.toBeInTheDocument();
expect(queryByTestId('filter-select-none')).not.toBeInTheDocument();
// Threshold moved into ActionFilterRings — the overview
// header keeps only the non-redundant controls.
expect(queryByTestId('filter-threshold')).not.toBeInTheDocument();
expect(queryByTestId('filter-threshold-input')).not.toBeInTheDocument();
expect(getByTestId('filter-show-unsimulated')).toBeInTheDocument();
});
it('header lays out the remaining filters on a single horizontal row (nowrap)', () => {
const { getByTestId } = render(
<ActionOverviewDiagram
{...defaultProps()}
filters={{ categories: allCategories, threshold: 1.5, showUnsimulated: false }}
/>,
);
const header = getByTestId('action-overview-header');
expect(header.style.flexWrap).toBe('nowrap');
// Remaining filters (unsimulated, combined-only) sit as
// direct descendants of the single row.
expect(header.querySelector('[data-testid="filter-show-unsimulated"]')).toBeTruthy();
expect(header.querySelector('[data-testid="filter-combined-only"]')).toBeTruthy();
});
it('disabling the red category hides red-severity pins from the overview', () => {
// defaultProps has one red pin (coupling_VL_FAR max_rho=1.1) and
// one green pin (disco_LINE_A max_rho=0.5).
const { container, rerender } = render(
<ActionOverviewDiagram
{...defaultProps()}
filters={{ categories: allCategories, threshold: 2.0, showUnsimulated: false }}
/>,
);
const pinsBefore = container.querySelectorAll('.nad-action-overview-pin:not(.nad-action-overview-pin-unsimulated)');
expect(pinsBefore.length).toBe(2);
rerender(
<ActionOverviewDiagram
{...defaultProps()}
filters={{
categories: { ...allCategories, red: false },
threshold: 2.0,
showUnsimulated: false,
}}
/>,
);
const pinsAfter = container.querySelectorAll('.nad-action-overview-pin:not(.nad-action-overview-pin-unsimulated)');
expect(pinsAfter.length).toBe(1);
expect(pinsAfter[0].getAttribute('data-action-id')).toBe('disco_LINE_A');
});
it('threshold cap hides actions whose max_rho exceeds it', () => {
// Threshold 1.0 keeps the green 0.5 pin; the red 1.1 pin is above cap.
const { container } = render(
<ActionOverviewDiagram
{...defaultProps()}
filters={{ categories: allCategories, threshold: 1.0, showUnsimulated: false }}
/>,
);
const pins = container.querySelectorAll('.nad-action-overview-pin:not(.nad-action-overview-pin-unsimulated)');
expect(pins.length).toBe(1);
expect(pins[0].getAttribute('data-action-id')).toBe('disco_LINE_A');
});
});
describe('unsimulated pins', () => {
const allCategories = { green: true, orange: true, red: true, grey: true };
it('does NOT render unsimulated pins when showUnsimulated is false', () => {
const { container } = render(
<ActionOverviewDiagram
{...defaultProps()}
filters={{ categories: allCategories, threshold: 1.5, showUnsimulated: false }}
unsimulatedActionIds={['LINE_D']}
/>,
);
const dimmed = container.querySelectorAll('.nad-action-overview-pin-unsimulated');
expect(dimmed.length).toBe(0);
});
it('renders dimmed, dashed pins when showUnsimulated is true', () => {
const { container } = render(
<ActionOverviewDiagram
{...defaultProps()}
filters={{ categories: allCategories, threshold: 1.5, showUnsimulated: true }}
unsimulatedActionIds={['LINE_D']}
/>,
);
const dimmed = container.querySelectorAll('.nad-action-overview-pin-unsimulated');
expect(dimmed.length).toBe(1);
expect(dimmed[0].getAttribute('data-unsimulated')).toBe('true');
expect(dimmed[0].getAttribute('opacity')).toBe('0.5');
});
it('skips unsimulated ids that are already simulated (no duplicate pin)', () => {
// LINE_A is already the target of `disco_LINE_A` in defaultProps;
// the unsimulated builder should drop it.
const { container } = render(
<ActionOverviewDiagram
{...defaultProps()}
filters={{ categories: allCategories, threshold: 1.5, showUnsimulated: true }}
unsimulatedActionIds={['disco_LINE_A', 'LINE_D']}
/>,
);
const dimmed = container.querySelectorAll('.nad-action-overview-pin-unsimulated');
expect(dimmed.length).toBe(1);
expect(dimmed[0].getAttribute('data-action-id')).toBe('LINE_D');
});
it('renders score metadata inside the unsimulated pin <title> when info is provided', () => {
// App.tsx computes `unsimulatedActionInfo` from
// action_scores and forwards it so the dimmed pin's
// hover tooltip carries the same data the Manual
// Selection dropdown exposes — operator can triage
// without leaving the overview.
const { container } = render(
<ActionOverviewDiagram
{...defaultProps()}
filters={{ categories: allCategories, threshold: 1.5, showUnsimulated: true }}
unsimulatedActionIds={['LINE_D']}
unsimulatedActionInfo={{
LINE_D: {
type: 'load_shedding',
score: 0.82,
mwStart: 24.5,
tapStart: null,
rankInType: 3,
countInType: 12,
maxScoreInType: 0.95,
},
}}
/>,
);
const pin = container.querySelector('.nad-action-overview-pin-unsimulated') as SVGGElement;
const title = pin.querySelector('title')?.textContent ?? '';
expect(title).toContain('LINE_D');
expect(title).toContain('Type: load_shedding');
expect(title).toContain('Score: 0.82');
expect(title).toContain('rank 3 of 12');
expect(title).toContain('max 0.95');
expect(title).toContain('MW start: 24.5 MW');
});
it('double-clicking an unsimulated pin fires onSimulateUnsimulatedAction', () => {
const onSimulateUnsimulatedAction = vi.fn();
const { container } = render(
<ActionOverviewDiagram
{...defaultProps()}
filters={{ categories: allCategories, threshold: 1.5, showUnsimulated: true }}
unsimulatedActionIds={['LINE_D']}
onSimulateUnsimulatedAction={onSimulateUnsimulatedAction}
/>,
);
const pin = container.querySelector('.nad-action-overview-pin-unsimulated') as SVGGElement;
expect(pin).not.toBeNull();
act(() => { fireEvent.dblClick(pin); });
expect(onSimulateUnsimulatedAction).toHaveBeenCalledTimes(1);
expect(onSimulateUnsimulatedAction.mock.calls[0][0]).toBe('LINE_D');
});
it('logs overview_unsimulated_toggled when the checkbox is flipped', () => {
const onFiltersChange = vi.fn();
const { getByTestId } = render(
<ActionOverviewDiagram
{...defaultProps()}
filters={{ categories: allCategories, threshold: 1.5, showUnsimulated: false }}
onFiltersChange={onFiltersChange}
/>,
);
const label = getByTestId('filter-show-unsimulated');
const checkbox = label.querySelector('input[type="checkbox"]') as HTMLInputElement;
fireEvent.click(checkbox);
expect(onFiltersChange).toHaveBeenCalledTimes(1);
expect(onFiltersChange.mock.calls[0][0].showUnsimulated).toBe(true);
});
// Regression: after the parent (App.tsx) resolves the
// simulation triggered by a double-click and adds the action
// to `result.actions`, the ActionOverviewDiagram must (a)
// drop the dimmed dashed pin from the un-simulated layer AND
// (b) render a fully-coloured pin for the id instead — same
// treatment as any action simulated via the Manual Selection
// dropdown.
it('recolours the pin when the action moves from scored-only to simulated', () => {
const baseProps = {
...defaultProps(),
filters: { categories: allCategories, threshold: 1.5, showUnsimulated: true },
unsimulatedActionIds: ['LINE_D'] as readonly string[],
};
const { container, rerender } = render(<ActionOverviewDiagram {...baseProps} />);
// Before: a single dimmed un-simulated pin for LINE_D.
const beforeUnsimulated = container.querySelectorAll('.nad-action-overview-pin-unsimulated');
expect(beforeUnsimulated.length).toBe(1);
expect(beforeUnsimulated[0].getAttribute('data-action-id')).toBe('LINE_D');
// App.tsx streams the simulation result and calls
// `wrappedManualActionAdded` → `handleManualActionAdded`
// which inserts the new entry into `result.actions`. The
// App then clears the id from `unsimulatedActionIds`
// because it now exists in `result.actions`. We mimic
// that two-prop update here.
const actionsAfter: Record<string, ActionDetail> = {
...baseProps.actions,
LINE_D: makeAction({
action_topology: { lines_ex_bus: { LINE_D: -1 }, lines_or_bus: { LINE_D: -1 }, gens_bus: {}, loads_bus: {} },
max_rho: 0.6, // green severity
max_rho_line: 'LINE_D',
is_manual: true,
}),
};
rerender(
<ActionOverviewDiagram
{...baseProps}
actions={actionsAfter}
unsimulatedActionIds={[]}
/>,
);
// After: no more dashed un-simulated pin for LINE_D.
const afterUnsimulated = container.querySelectorAll('.nad-action-overview-pin-unsimulated');
expect(afterUnsimulated.length).toBe(0);
// And LINE_D is now a regular, fully-coloured pin (not
// dashed, not dimmed, not marked unsimulated).
const linePin = container.querySelector('[data-action-id="LINE_D"]');
expect(linePin).not.toBeNull();
expect(linePin!.classList.contains('nad-action-overview-pin-unsimulated')).toBe(false);
expect(linePin!.getAttribute('data-dimmed-by-filter')).toBeNull();
expect(linePin!.getAttribute('data-unsimulated')).toBeNull();
expect(linePin!.getAttribute('opacity')).toBeNull();
});
it('recolours the pin even when the "Show unsimulated" toggle is OFF', () => {
// Before simulation with the toggle off, the pin isn't
// drawn at all (no dashed preview). After simulation it
// must still appear as a regular coloured pin — the
// toggle only gates the un-simulated preview layer.
const baseProps = {
...defaultProps(),
filters: { categories: allCategories, threshold: 1.5, showUnsimulated: false },
unsimulatedActionIds: ['LINE_D'] as readonly string[],
};
const { container, rerender } = render(<ActionOverviewDiagram {...baseProps} />);
expect(container.querySelector('[data-action-id="LINE_D"]')).toBeNull();
const actionsAfter: Record<string, ActionDetail> = {
...baseProps.actions,
LINE_D: makeAction({
action_topology: { lines_ex_bus: { LINE_D: -1 }, lines_or_bus: { LINE_D: -1 }, gens_bus: {}, loads_bus: {} },
max_rho: 0.6,
max_rho_line: 'LINE_D',
is_manual: true,
}),
};
rerender(
<ActionOverviewDiagram
{...baseProps}
actions={actionsAfter}
unsimulatedActionIds={[]}
/>,
);
const linePin = container.querySelector('[data-action-id="LINE_D"]');
expect(linePin).not.toBeNull();
expect(linePin!.classList.contains('nad-action-overview-pin-unsimulated')).toBe(false);
});
it('shows the now-selected status on the recoloured pin (gold star) when the parent starred it', () => {
// Manual-selection flow (the one unsimulated double-click
// mirrors) adds the id to selectedActionIds too. The
// coloured pin should then pick up the selected styling
// (the gold star above the teardrop).
const baseProps = {
...defaultProps(),
filters: { categories: allCategories, threshold: 1.5, showUnsimulated: true },
unsimulatedActionIds: ['LINE_D'] as readonly string[],
};
const { container, rerender } = render(<ActionOverviewDiagram {...baseProps} />);
const actionsAfter: Record<string, ActionDetail> = {
...baseProps.actions,
LINE_D: makeAction({
action_topology: { lines_ex_bus: { LINE_D: -1 }, lines_or_bus: { LINE_D: -1 }, gens_bus: {}, loads_bus: {} },
max_rho: 0.6,
max_rho_line: 'LINE_D',
is_manual: true,
}),
};
rerender(
<ActionOverviewDiagram
{...baseProps}
actions={actionsAfter}
unsimulatedActionIds={[]}
selectedActionIds={new Set(['LINE_D'])}
/>,
);
const linePin = container.querySelector('[data-action-id="LINE_D"]');
expect(linePin).not.toBeNull();
// Selected pins get a gold star path — see
// applyActionOverviewPins. The star element is the only
// <path> carrying a #eab308 fill.
const goldFills = Array.from(linePin!.querySelectorAll('path'))
.filter(p => p.getAttribute('fill') === '#eab308');
expect(goldFills.length).toBeGreaterThan(0);
});
});
describe('action-type chip filter', () => {
const allCategories = { green: true, orange: true, red: true, grey: true };
const propsWithMixedTypes = () => {
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.5,
max_rho_line: 'LINE_A',
description_unitaire: "Ouverture de la ligne 'LINE_A'",
}),
'coupling_VL_FAR': makeAction({
description_unitaire: "Ouverture du poste 'VL_FAR'",
max_rho: 0.8,
max_rho_line: 'LINE_B',
}),
};
return { ...defaultProps(), actions };
};
it('hides pins whose classified type does not match the active chip', () => {
// DISCO-only filter → disco_LINE_A stays, coupling_VL_FAR drops.
const { container } = render(
<ActionOverviewDiagram
{...propsWithMixedTypes()}
filters={{ categories: allCategories, threshold: 2.0, showUnsimulated: false, actionType: 'disco' }}
/>,
);
const pins = container.querySelectorAll('.nad-action-overview-pin:not(.nad-action-overview-pin-unsimulated)');
const ids = Array.from(pins).map(p => p.getAttribute('data-action-id'));
expect(ids).toEqual(['disco_LINE_A']);
});
it('ALL restores every pin', () => {
const { container, rerender } = render(
<ActionOverviewDiagram
{...propsWithMixedTypes()}
filters={{ categories: allCategories, threshold: 2.0, showUnsimulated: false, actionType: 'open' }}
/>,
);
// With 'open' we keep the coupling pin only
expect(container.querySelectorAll('.nad-action-overview-pin:not(.nad-action-overview-pin-unsimulated)').length).toBe(1);
rerender(
<ActionOverviewDiagram
{...propsWithMixedTypes()}
filters={{ categories: allCategories, threshold: 2.0, showUnsimulated: false, actionType: 'all' }}
/>,
);
expect(container.querySelectorAll('.nad-action-overview-pin:not(.nad-action-overview-pin-unsimulated)').length).toBe(2);
});
it('filters un-simulated pins by matching the score-info type', () => {
const { container } = render(
<ActionOverviewDiagram
{...defaultProps()}
filters={{ categories: allCategories, threshold: 2.0, showUnsimulated: true, actionType: 'pst' }}
unsimulatedActionIds={['LINE_D', 'LINE_A']}
unsimulatedActionInfo={{
LINE_D: { type: 'pst_tap_change', score: 0.5, mwStart: null, tapStart: null, rankInType: 1, countInType: 1, maxScoreInType: 0.5 },
LINE_A: { type: 'line_disconnection', score: 0.8, mwStart: null, tapStart: null, rankInType: 1, countInType: 1, maxScoreInType: 0.8 },
}}
/>,
);
const dimmed = container.querySelectorAll('.nad-action-overview-pin-unsimulated');
const dimmedIds = Array.from(dimmed).map(p => p.getAttribute('data-action-id'));
// Only the PST-scored un-simulated pin survives.
expect(dimmedIds).toEqual(['LINE_D']);
});
});
describe('detached-window popover placement', () => {
// Regression: the popover placement heuristic used to call
// `window.innerWidth/innerHeight` unconditionally, so when the
// overview was portal'd into a detached popup the above/below
// decision was based on the MAIN window's dimensions — leading
// to popovers that covered the pin. The fix reads
// `ownerDocument.defaultView` so the popup's own viewport
// drives the placement.
//
// In jsdom the default window height is 768; we stub it per
// test to exercise both branches.
const withViewport = (height: number, fn: () => void) => {
const originalInner = window.innerHeight;
Object.defineProperty(window, 'innerHeight', { configurable: true, value: height });
try { fn(); } finally {
Object.defineProperty(window, 'innerHeight', { configurable: true, value: originalInner });
}
};
const clickFirstPin = (container: HTMLElement, screenY: number) => {
const pin = container.querySelector('.nad-action-overview-pin:not(.nad-action-overview-pin-unsimulated)') as SVGGElement;
// getBoundingClientRect is not implemented for SVG in
// jsdom — stub it so handlePinClick's click handler can
// read the pin's on-screen position deterministically.
Object.defineProperty(pin, 'getBoundingClientRect', {
configurable: true,
value: () => ({ left: 400, top: screenY, width: 20, height: 20, right: 420, bottom: screenY + 20, x: 400, y: screenY, toJSON: () => ({}) }),
});
act(() => {
fireEvent.click(pin);
// applyActionOverviewPins debounces single-click by
// PIN_SINGLE_CLICK_DELAY_MS; advance timers so the
// popover commits.
vi.advanceTimersByTime(300);
});
};
beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.useRealTimers(); });
it('places popover BELOW when the pin sits high in a tall viewport', () => {
withViewport(1400, () => {
const { container, getByTestId } = render(<ActionOverviewDiagram {...defaultProps()} />);
clickFirstPin(container, 200);
const pop = getByTestId('action-overview-popover');
expect(pop.getAttribute('data-place-above')).toBe('false');
});
});
it('places popover ABOVE when the pin sits low in a short viewport', () => {
withViewport(500, () => {
const { container, getByTestId } = render(<ActionOverviewDiagram {...defaultProps()} />);
clickFirstPin(container, 400);
const pop = getByTestId('action-overview-popover');
expect(pop.getAttribute('data-place-above')).toBe('true');
});
});
});
describe('combined-action pin protection', () => {
// Regression: when a combined-action pin passes the overview
// filter, its two constituent unitary pins must stay visible
// (even if the individual filter would hide them), otherwise
// the combined-pin curve would dangle. Filtered-but-kept
// constituents are dimmed to read as context rather than
// first-class actions.
const allCategories = { green: true, orange: true, red: true, grey: true };
const propsWithCombined = () => {
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.5,
max_rho_line: 'LINE_A',
}),
'disco_LINE_B': makeAction({
// Red severity — would be filtered out if 'red' is off.
action_topology: { lines_ex_bus: { LINE_B: -1 }, lines_or_bus: { LINE_B: -1 }, gens_bus: {}, loads_bus: {} },
max_rho: 1.3,
max_rho_line: 'LINE_B',
}),
// Combined pair — GREEN severity so it passes even
// when red is filtered out. Must keep both unitary
// constituents visible.
'disco_LINE_A+disco_LINE_B': makeAction({
description_unitaire: 'A + B combined',
rho_after: [0.6, 0.7],
rho_before: [0.8, 0.9],
max_rho: 0.7,
max_rho_line: 'LINE_A',
is_rho_reduction: true,
}),
};
return {
...defaultProps(),
actions,
overloadedLines: [] as readonly string[],
contingency: null as string | null,
};
};
it('keeps constituent pins visible but DIMMED when only the combined pin passes the filter', () => {
const { container } = render(
<ActionOverviewDiagram
{...propsWithCombined()}
filters={{
// Hide the red bucketdisco_LINE_B would
// normally disappear. But the combined pair
// (green) passes, so both unitary pins must
// survive, with disco_LINE_B dimmed.
categories: { ...allCategories, red: false },
threshold: 2.0,
showUnsimulated: false,
}}
/>,
);
const pinA = container.querySelector('[data-action-id="disco_LINE_A"]');
const pinB = container.querySelector('[data-action-id="disco_LINE_B"]');
expect(pinA).not.toBeNull();
expect(pinB).not.toBeNull();
// disco_LINE_A passes the filter itself → not dimmed.
expect(pinA!.getAttribute('data-dimmed-by-filter')).toBeNull();
// disco_LINE_B fails the filter but is protected by the
// passing combined pin → kept with a dim flag.
expect(pinB!.getAttribute('data-dimmed-by-filter')).toBe('true');
expect(pinB!.getAttribute('opacity')).toBe('0.4');
// The combined pin itself is present.
const combined = container.querySelector('[data-action-id="disco_LINE_A+disco_LINE_B"]');
expect(combined).not.toBeNull();
});
it('DROPS constituent pins when neither the combined pin nor the unitary passes the filter', () => {
const { container } = render(
<ActionOverviewDiagram
{...propsWithCombined()}
filters={{
// Hide red AND greenboth the combined
// (green) and disco_LINE_B (red) fail; only
// the combined-protection branch could save
// disco_LINE_B, and it no longer passes.
categories: { green: false, orange: true, red: false, grey: true },
threshold: 2.0,
showUnsimulated: false,
}}
/>,
);
expect(container.querySelector('[data-action-id="disco_LINE_A"]')).toBeNull();
expect(container.querySelector('[data-action-id="disco_LINE_B"]')).toBeNull();
expect(container.querySelector('[data-action-id="disco_LINE_A+disco_LINE_B"]')).toBeNull();
});
it('does NOT dim constituents when the combined pin is filtered out and the unitaries would be filtered too', () => {
// Combined pin is green — disable green → combined is
// out. disco_LINE_B (red) is also out → it should simply
// disappear, NOT appear dimmed.
const { container } = render(
<ActionOverviewDiagram
{...propsWithCombined()}
filters={{
categories: { ...allCategories, green: false, red: false },
threshold: 2.0,
showUnsimulated: false,
}}
/>,
);
expect(container.querySelector('[data-action-id="disco_LINE_B"]')).toBeNull();
});
});
// ----------------------------------------------------------------
// "Combined only" toggle. Adds a new pin-scoped filter that
// hides every unitary / un-simulated pin that is NOT a
// constituent of a passing combined pair, so the operator can
// focus on the recommender's multi-action remediations.
// ----------------------------------------------------------------
describe('combined-only filter', () => {
const allCategoriesCO = { green: true, orange: true, red: true, grey: true };
const propsWithMix = () => {
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.5,
max_rho_line: 'LINE_A',
}),
'disco_LINE_B': makeAction({
action_topology: { lines_ex_bus: { LINE_B: -1 }, lines_or_bus: { LINE_B: -1 }, gens_bus: {}, loads_bus: {} },
max_rho: 0.5,
max_rho_line: 'LINE_B',
}),
// Solo pin — no combined pair references it. Must be
// hidden in combined-only mode.
'disco_LINE_C': makeAction({
action_topology: { lines_ex_bus: { LINE_C: -1 }, lines_or_bus: { LINE_C: -1 }, gens_bus: {}, loads_bus: {} },
max_rho: 0.5,
max_rho_line: 'LINE_C',
}),
'disco_LINE_A+disco_LINE_B': makeAction({
description_unitaire: 'A + B combined',
rho_after: [0.6, 0.7],
rho_before: [0.8, 0.9],
max_rho: 0.7,
max_rho_line: 'LINE_A',
is_rho_reduction: true,
}),
};
return {
...defaultProps(),
actions,
overloadedLines: [] as readonly string[],
contingency: null as string | null,
};
};
it('renders the "Combined only" checkbox in the filter row', () => {
const { getByTestId } = render(
<ActionOverviewDiagram
{...defaultProps()}
filters={{
categories: allCategoriesCO,
threshold: 1.5,
showUnsimulated: false,
actionType: 'all',
showCombinedOnly: false,
}}
/>,
);
const label = getByTestId('filter-combined-only');
expect(label).toBeInTheDocument();
const checkbox = label.querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(checkbox).not.toBeNull();
expect(checkbox.checked).toBe(false);
});
it('clicking the checkbox fires onFiltersChange with showCombinedOnly: true', () => {
const onFiltersChange = vi.fn();
const { getByTestId } = render(
<ActionOverviewDiagram
{...defaultProps()}
filters={{
categories: allCategoriesCO,
threshold: 1.5,
showUnsimulated: false,
actionType: 'all',
showCombinedOnly: false,
}}
onFiltersChange={onFiltersChange}
/>,
);
const label = getByTestId('filter-combined-only');
const checkbox = label.querySelector('input[type="checkbox"]') as HTMLInputElement;
fireEvent.click(checkbox);
expect(onFiltersChange).toHaveBeenCalledTimes(1);
expect(onFiltersChange.mock.calls[0][0].showCombinedOnly).toBe(true);
});
it('hides the solo pin and dims the combined-pair constituents when toggled on', () => {
const { container } = render(
<ActionOverviewDiagram
{...propsWithMix()}
filters={{
categories: allCategoriesCO,
threshold: 2.0,
showUnsimulated: false,
actionType: 'all',
showCombinedOnly: true,
}}
/>,
);
// Solo pin disappears.
expect(container.querySelector('[data-action-id="disco_LINE_C"]')).toBeNull();
// Pair constituents stay, dimmed.
const pinA = container.querySelector('[data-action-id="disco_LINE_A"]');
const pinB = container.querySelector('[data-action-id="disco_LINE_B"]');
expect(pinA).not.toBeNull();
expect(pinB).not.toBeNull();
expect(pinA!.getAttribute('data-dimmed-by-filter')).toBe('true');
expect(pinB!.getAttribute('data-dimmed-by-filter')).toBe('true');
// Combined pin itself is rendered.
expect(container.querySelector('[data-action-id="disco_LINE_A+disco_LINE_B"]')).not.toBeNull();
});
it('keeps every pin at full strength when toggled off', () => {
const { container } = render(
<ActionOverviewDiagram
{...propsWithMix()}
filters={{
categories: allCategoriesCO,
threshold: 2.0,
showUnsimulated: false,
actionType: 'all',
showCombinedOnly: false,
}}
/>,
);
// Solo pin survives.
expect(container.querySelector('[data-action-id="disco_LINE_C"]')).not.toBeNull();
// Constituents are NOT dimmed when the combined pin
// doesn't need them as protected context.
const pinA = container.querySelector('[data-action-id="disco_LINE_A"]');
expect(pinA!.getAttribute('data-dimmed-by-filter')).toBeNull();
});
it('drops every un-simulated pin when toggled on (un-simulated never combined)', () => {
const props = propsWithMix();
const { container } = render(
<ActionOverviewDiagram
{...props}
unsimulatedActionIds={['LINE_D']}
filters={{
categories: allCategoriesCO,
threshold: 2.0,
showUnsimulated: true,
actionType: 'all',
showCombinedOnly: true,
}}
/>,
);
// Even with showUnsimulated=true, no un-simulated pin is
// rendered when the operator restricts to combined-only.
const unsimulated = container.querySelectorAll(
'.nad-action-overview-pin-unsimulated',
);
expect(unsimulated.length).toBe(0);
});
});
});