// 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 => ({ 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 // - `` stubs for every line referenced by // the metadata index so `applyActionOverviewHighlights` can // locate and clone the contingency / overload edges. const makeN1Diagram = (): DiagramData => ({ svg: '' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + '', metadata: null, lines_overloaded: ['LINE_B'], lines_overloaded_rho: [1.03], }); const makeActions = (): Record => ({ '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(); // 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(); 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(); const host = container.querySelector('.nad-action-overview-container')!; expect(host.querySelector('svg')).not.toBeNull(); rerender(); expect(host.querySelector('svg')).toBeNull(); }); it('renders one pin per simulated action using the severity palette', () => { const { container } = render(); 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(); // 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(); 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(); 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( , ); 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(); // 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(); // 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(); // The counter is now a compact 📍 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( , ); 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( , ); expect(getByText(/Run.*Analyze.*Suggest/)).toBeInTheDocument(); }); it('shows the "no background" empty state when no N-1 diagram is loaded', () => { const { getByText } = render( , ); expect(getByText(/Load a contingency first/)).toBeInTheDocument(); }); describe('local zoom controls', () => { it('clicking "+" shrinks the viewBox around its centre', () => { const { container, getByTitle } = render(); 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(); 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(); 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(); 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( , ); 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(); 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( , ); 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(); // 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(); 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(); 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(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(); 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(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(); 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(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(); 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(); 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(); 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(); 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(); 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 wrapper created an SVG // transparency group (Layerize: 31s). CSS per-child // opacity still created stacking contexts (25s). Now we // use a single white overlay — zero stacking contexts. const { container } = render(); 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 opacity wrapper should exist. expect(svg.querySelector('g.nad-overview-dim-layer')).toBeNull(); }); it('dim rect covers the viewBox with margin', () => { const { container } = render(); 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(); 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(); 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(); // 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( , ); 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( , ); 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(); 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( , ); 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( , ); 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( , ); 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(); 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(); 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(); 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( , ); 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( , ); 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(); expect(lastLog('overview_shown')).toBeDefined(); expect(lastLog('overview_shown')!.details).toHaveProperty('pin_count'); }); it('logs overview_hidden when visible flips false', () => { const { rerender } = render(); rerender(); expect(lastLog('overview_hidden')).toBeDefined(); }); it('logs overview_pin_clicked on single-click (after the delay)', () => { const { container } = render(); 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(); 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(); 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(); 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(); 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(); 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(); 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( , ); 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( , ); 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( , ); const pinsBefore = container.querySelectorAll('.nad-action-overview-pin:not(.nad-action-overview-pin-unsimulated)'); expect(pinsBefore.length).toBe(2); rerender( , ); 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( , ); 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( , ); 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( , ); 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( , ); 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 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 bucket — disco_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 green — both 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); }); }); });