Spaces:
Sleeping
Sleeping
| // 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 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); | |
| }); | |
| }); | |
| }); | |