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 React from 'react'; | |
| import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; | |
| import { render, screen, waitFor, act, fireEvent, cleanup, within } from '@testing-library/react'; | |
| import userEvent from '@testing-library/user-event'; | |
| import '@testing-library/jest-dom/vitest'; | |
| import App from './App'; | |
| // ===== Mocks ===== | |
| // Mock child components to avoid their complexity | |
| vi.mock('./components/VisualizationPanel', () => { | |
| interface MockProps { | |
| nDiagram: Record<string, unknown> | null; | |
| n1Diagram: Record<string, unknown> | null; | |
| activeTab: string; | |
| configLoading: boolean; | |
| layoutPath: string; | |
| networkPath: string; | |
| onOpenSettings: (tab: string) => void; | |
| } | |
| const MockVisualizationPanel = (props: MockProps) => { | |
| const { nDiagram, n1Diagram, activeTab, configLoading, layoutPath, networkPath, onOpenSettings } = props; | |
| const [warningDismissed, setWarningDismissed] = React.useState(false); | |
| const hasAnyDiagram = !!nDiagram?.svg || !!n1Diagram?.svg; | |
| const showPathWarning = !warningDismissed && !hasAnyDiagram; | |
| return ( | |
| <div | |
| data-testid="visualization-panel" | |
| data-n1-diagram-present={!!n1Diagram} | |
| data-active-tab={activeTab} | |
| > | |
| {!hasAnyDiagram && !configLoading && showPathWarning && ( | |
| <div> | |
| <div>Configuration Paths</div> | |
| <button onClick={() => setWarningDismissed(true)}>✕</button> | |
| <div>Layout Path: {layoutPath}</div> | |
| <div>Output Folder: {networkPath ? (networkPath.includes('/') ? networkPath.substring(0, networkPath.lastIndexOf('/')) : networkPath) : 'Not set'}</div> | |
| <button onClick={() => onOpenSettings('paths')}>Change in settings</button> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| return { default: MockVisualizationPanel }; | |
| }); | |
| vi.mock('./components/ActionFeed', () => ({ | |
| default: (props: { linesOverloaded: string[]; pendingAnalysisResult: object | null; analysisLoading: boolean; onDisplayPrioritizedActions: () => void; onRunAnalysis: () => void; canRunAnalysis: boolean }) => ( | |
| <div | |
| data-testid="action-feed" | |
| data-ol-count={props.linesOverloaded?.length || 0} | |
| data-ol-names={(props.linesOverloaded ?? []).join('|')} | |
| data-pending={!!props.pendingAnalysisResult} | |
| data-loading={!!props.analysisLoading} | |
| > | |
| <div> | |
| <h3 data-testid="action-feed-header">Remedial Actions</h3> | |
| </div> | |
| {props.analysisLoading ? ( | |
| <button disabled>⚙️ Analyzing…</button> | |
| ) : props.pendingAnalysisResult ? ( | |
| <button onClick={props.onDisplayPrioritizedActions}>Display prioritized actions</button> | |
| ) : ( | |
| <button onClick={props.onRunAnalysis} disabled={!props.canRunAnalysis}>🔍 Analyze & Suggest</button> | |
| )} | |
| </div> | |
| ), | |
| })); | |
| vi.mock('./components/OverloadPanel', () => ({ | |
| default: (props: { n1Overloads: string[]; selectedOverloads: Set<string>; onToggleOverload?: (overload: string) => void }) => ( | |
| <div | |
| data-testid="overload-panel" | |
| data-n1-ol-count={props.n1Overloads?.length || 0} | |
| data-sel-ol-count={props.selectedOverloads?.size || 0} | |
| > | |
| {(props.n1Overloads ?? []).map((name) => ( | |
| <button | |
| key={name} | |
| data-testid={`toggle-overload-${name}`} | |
| onClick={() => props.onToggleOverload?.(name)} | |
| /> | |
| ))} | |
| </div> | |
| ), | |
| })); | |
| // Mock hooks | |
| vi.mock('./hooks/usePanZoom', () => ({ | |
| usePanZoom: () => ({ viewBox: null, setViewBox: vi.fn() }), | |
| })); | |
| // Mock hooks | |
| vi.mock('./hooks/usePanZoom', () => ({ | |
| usePanZoom: () => ({ viewBox: null, setViewBox: vi.fn() }), | |
| })); | |
| // Mock SVG utilities | |
| vi.mock('./utils/svgUtils', () => ({ | |
| processSvg: (svg: string) => ({ svg, viewBox: { x: 0, y: 0, w: 100, h: 100 } }), | |
| buildMetadataIndex: () => null, | |
| applyOverloadedHighlights: vi.fn(), | |
| applyDeltaVisuals: vi.fn(), | |
| applyActionTargetHighlights: vi.fn(), | |
| applyContingencyHighlight: vi.fn(), | |
| getIdMap: () => new Map(), | |
| invalidateIdMapCache: vi.fn(), | |
| isCouplingAction: vi.fn(() => false), | |
| attachVlInteractions: vi.fn(() => () => {}), | |
| })); | |
| // Mock API — use vi.hoisted to define mock before vi.mock hoists | |
| const mockApi = vi.hoisted(() => ({ | |
| updateConfig: vi.fn().mockResolvedValue({ monitored_lines_count: 10, total_lines_count: 10 }), | |
| getBranches: vi.fn().mockResolvedValue({ branches: ['BRANCH_A', 'BRANCH_B', 'BRANCH_C'], name_map: {} }), | |
| getVoltageLevels: vi.fn().mockResolvedValue({ voltage_levels: ['VL1', 'VL2'], name_map: {} }), | |
| getNominalVoltages: vi.fn().mockResolvedValue({ mapping: {}, unique_kv: [63, 225] }), | |
| getVoltageLevelSubstations: vi.fn().mockResolvedValue({ mapping: {} }), | |
| getNetworkDiagram: vi.fn().mockResolvedValue({ svg: '<svg></svg>', metadata: null }), | |
| getContingencyDiagram: vi.fn().mockResolvedValue({ svg: '<svg></svg>', metadata: null, lines_overloaded: [] }), | |
| pickPath: vi.fn(), | |
| runAnalysisStep1: vi.fn().mockResolvedValue({ can_proceed: true, lines_overloaded: ['LINE_OL1'] }), | |
| runAnalysisStep2Stream: vi.fn(), | |
| getActionVariantDiagram: vi.fn().mockResolvedValue({ svg: '<svg></svg>', metadata: null }), | |
| getUserConfig: vi.fn().mockResolvedValue({ | |
| network_path: '/home/user/data/grid.xiidm', | |
| action_file_path: '/home/user/data/actions.json' | |
| }), | |
| getConfigFilePath: vi.fn().mockResolvedValue('/home/user/data/config.json'), | |
| saveUserConfig: vi.fn().mockResolvedValue({}), | |
| setConfigFilePath: vi.fn().mockResolvedValue({ config_file_path: '/home/user/data/config.json', config: {} }), | |
| getNSld: vi.fn(), | |
| getContingencySld: vi.fn(), | |
| getActionVariantSld: vi.fn(), | |
| })); | |
| vi.mock('./api', () => ({ | |
| api: mockApi, | |
| })); | |
| afterEach(() => { | |
| cleanup(); | |
| }); | |
| // Helper: render App, load config, wait for branches to appear | |
| async function renderAndLoadStudy() { | |
| render(<App />); | |
| // Click Load Study | |
| const loadBtn = screen.getByText('🔄 Load Study'); | |
| await userEvent.click(loadBtn); | |
| // Wait for branches to be loaded (which means handleLoadConfig is done) | |
| await waitFor(() => { | |
| expect(screen.getByText('⚡ Select Contingency')).toBeInTheDocument(); | |
| }, { timeout: 5000 }); | |
| } | |
| // Helper: add ``branchName`` to the pending contingency by typing it | |
| // into the react-select multi-select and pressing Enter to confirm | |
| // the highlighted option. After a contingency has already been | |
| // committed the picker card is hidden behind the sticky banner Clear | |
| // shortcut, so transparently click Clear (and confirm the dialog when | |
| // analysis state would be lost) to reveal the picker again. | |
| async function pickBranch(branchName: string) { | |
| let combobox = screen.queryByRole('combobox'); | |
| if (!combobox) { | |
| const clearBtn = screen.queryByTestId('sidebar-summary-clear'); | |
| if (clearBtn) { | |
| await act(async () => { await userEvent.click(clearBtn); }); | |
| const confirmDialog = screen.queryByText('Change Contingency?'); | |
| if (confirmDialog) { | |
| await act(async () => { await userEvent.click(screen.getByText('Confirm')); }); | |
| } | |
| } | |
| combobox = screen.getByRole('combobox'); | |
| } | |
| await act(async () => { | |
| await userEvent.click(combobox); | |
| await userEvent.type(combobox, branchName); | |
| await userEvent.keyboard('{Enter}'); | |
| }); | |
| } | |
| // Helper: click the Trigger button to commit the pending list. | |
| async function triggerContingency() { | |
| const trigger = await screen.findByRole('button', { name: /Trigger/ }); | |
| await act(async () => { | |
| await userEvent.click(trigger); | |
| }); | |
| } | |
| // Helper: full pick + trigger + wait for the contingency-diagram fetch. | |
| async function selectBranch(branchName: string) { | |
| await pickBranch(branchName); | |
| await triggerContingency(); | |
| await waitFor(() => { | |
| expect(mockApi.getContingencyDiagram).toHaveBeenCalledWith([branchName]); | |
| }); | |
| } | |
| // Helper: run analysis to create analysis state | |
| async function runAnalysis() { | |
| // Mock runAnalysisStep2Stream to return a streaming Response | |
| const mockStream = new ReadableStream({ | |
| start(controller) { | |
| controller.enqueue(new TextEncoder().encode( | |
| JSON.stringify({ type: 'result', actions: { ACT1: { is_manual: false, rho_before: [1.02], rho_after: [0.95] } }, lines_overloaded: ['LINE_OL1'], message: 'done', dc_fallback: false }) + '\n' | |
| )); | |
| controller.close(); | |
| }, | |
| }); | |
| mockApi.runAnalysisStep2Stream.mockResolvedValue({ | |
| ok: true, | |
| body: mockStream, | |
| }); | |
| const runBtn = screen.getByText('🔍 Analyze & Suggest'); | |
| await act(async () => { | |
| await userEvent.click(runBtn); | |
| }); | |
| await waitFor(() => { | |
| const running = screen.queryByText('⚙️ Analyzing…'); | |
| if (running) throw new Error('Still running...'); | |
| }, { timeout: 5000 }); | |
| // Click Display Actions if present | |
| const displayBtn = await screen.findByText(/Display.*prioritized actions/, {}, { timeout: 3000 }); | |
| await userEvent.click(displayBtn); | |
| } | |
| describe('Contingency Change Confirmation', () => { | |
| beforeEach(() => { | |
| vi.clearAllMocks(); | |
| localStorage.clear(); | |
| // Reset fetch stub | |
| vi.unstubAllGlobals(); | |
| }); | |
| it('does NOT show confirmation dialog when clearing without analysis state', async () => { | |
| await renderAndLoadStudy(); | |
| await selectBranch('BRANCH_A'); | |
| // Clear from the sticky banner. No analysis state has been built | |
| // up yet, so the Clear shortcut bypasses the confirmation dialog | |
| // and the picker reappears immediately. | |
| mockApi.getContingencyDiagram.mockClear(); | |
| await act(async () => { | |
| await userEvent.click(screen.getByTestId('sidebar-summary-clear')); | |
| }); | |
| expect(screen.queryByText('Change Contingency?')).not.toBeInTheDocument(); | |
| // Pick a fresh contingency through the now-visible picker. | |
| await pickBranch('BRANCH_B'); | |
| await triggerContingency(); | |
| await waitFor(() => { | |
| expect(mockApi.getContingencyDiagram).toHaveBeenCalledWith(['BRANCH_B']); | |
| }); | |
| }); | |
| it('shows confirmation dialog when clearing after running analysis', async () => { | |
| await renderAndLoadStudy(); | |
| await selectBranch('BRANCH_A'); | |
| await runAnalysis(); | |
| await act(async () => { | |
| await userEvent.click(screen.getByTestId('sidebar-summary-clear')); | |
| }); | |
| await waitFor(() => { | |
| expect(screen.getByText('Change Contingency?')).toBeInTheDocument(); | |
| }, { timeout: 3000 }); | |
| }); | |
| it('proceeds with contingency change after confirmation', async () => { | |
| await renderAndLoadStudy(); | |
| await selectBranch('BRANCH_A'); | |
| await runAnalysis(); | |
| await act(async () => { | |
| await userEvent.click(screen.getByTestId('sidebar-summary-clear')); | |
| }); | |
| await screen.findByText('Change Contingency?'); | |
| await userEvent.click(screen.getByText('Confirm')); | |
| mockApi.getContingencyDiagram.mockClear(); | |
| await pickBranch('BRANCH_B'); | |
| await triggerContingency(); | |
| await waitFor(() => { | |
| expect(mockApi.getContingencyDiagram).toHaveBeenCalledWith(['BRANCH_B']); | |
| }); | |
| expect(screen.queryByText('Change Contingency?')).not.toBeInTheDocument(); | |
| }); | |
| it('cancels contingency clear on dismissal', async () => { | |
| await renderAndLoadStudy(); | |
| await selectBranch('BRANCH_A'); | |
| await runAnalysis(); | |
| await act(async () => { | |
| await userEvent.click(screen.getByTestId('sidebar-summary-clear')); | |
| }); | |
| await screen.findByText('Change Contingency?'); | |
| await userEvent.click(screen.getByText('Cancel')); | |
| // BRANCH_A stays applied; its name still surfaces in the sticky | |
| // banner. (Use getAllByText since both the banner link and the | |
| // action-card data attribute may surface the name.) | |
| expect(screen.getAllByText('BRANCH_A').length).toBeGreaterThan(0); | |
| }); | |
| it('does not trigger a clear dialog for partial/invalid branch text', async () => { | |
| await renderAndLoadStudy(); | |
| // The contingency picker stays visible until the operator | |
| // triggers a contingency, so partial input via the multi-select | |
| // never affects the banner / Clear flow. | |
| const combobox = screen.getByRole('combobox'); | |
| await userEvent.click(combobox); | |
| await userEvent.type(combobox, 'INVALID_NAME'); | |
| expect(screen.queryByText('Change Contingency?')).not.toBeInTheDocument(); | |
| }); | |
| it('surfaces an analysis stream error in the status toast (QW4)', async () => { | |
| await renderAndLoadStudy(); | |
| await selectBranch('BRANCH_A'); | |
| // Step 2 emits an `error` event. `useAnalysis` records it on its OWN | |
| // `error` state (not `infoMessage`), which nothing rendered before | |
| // QW4; the fix wires that hook error into the shared StatusToasts | |
| // channel so an analysis failure is no longer a silent dead spinner. | |
| const errStream = new ReadableStream({ | |
| start(controller) { | |
| controller.enqueue(new TextEncoder().encode( | |
| JSON.stringify({ type: 'error', message: 'Backend crashed' }) + '\n' | |
| )); | |
| controller.close(); | |
| }, | |
| }); | |
| mockApi.runAnalysisStep2Stream.mockResolvedValue({ ok: true, body: errStream }); | |
| await act(async () => { | |
| await userEvent.click(screen.getByText('🔍 Analyze & Suggest')); | |
| }); | |
| expect( | |
| await screen.findByText(/Analysis failed: Backend crashed/, {}, { timeout: 3000 }) | |
| ).toBeInTheDocument(); | |
| }); | |
| it('dismisses a surfaced analysis error when the contingency is cleared (QW4)', async () => { | |
| await renderAndLoadStudy(); | |
| await selectBranch('BRANCH_A'); | |
| const errStream = new ReadableStream({ | |
| start(controller) { | |
| controller.enqueue(new TextEncoder().encode( | |
| JSON.stringify({ type: 'error', message: 'Backend crashed' }) + '\n' | |
| )); | |
| controller.close(); | |
| }, | |
| }); | |
| mockApi.runAnalysisStep2Stream.mockResolvedValue({ ok: true, body: errStream }); | |
| await act(async () => { | |
| await userEvent.click(screen.getByText('🔍 Analyze & Suggest')); | |
| }); | |
| await screen.findByText(/Analysis failed: Backend crashed/, {}, { timeout: 3000 }); | |
| // Clearing the contingency must also clear the analysis-hook error — | |
| // otherwise the failure banner would outlive the state it describes. | |
| await act(async () => { | |
| await userEvent.click(screen.getByTestId('sidebar-summary-clear')); | |
| }); | |
| const maybeConfirm = screen.queryByText('Confirm'); | |
| if (maybeConfirm) { | |
| await act(async () => { await userEvent.click(maybeConfirm); }); | |
| } | |
| await waitFor(() => { | |
| expect(screen.queryByText(/Analysis failed: Backend crashed/)).not.toBeInTheDocument(); | |
| }, { timeout: 3000 }); | |
| }); | |
| }); | |
| describe('Overload Clearing Logic', () => { | |
| beforeEach(() => { | |
| cleanup(); | |
| vi.clearAllMocks(); | |
| Object.values(mockApi).forEach(m => { | |
| if (vi.isMockFunction(m)) m.mockReset(); | |
| }); | |
| // Restore defaults after reset | |
| mockApi.updateConfig.mockResolvedValue({ monitored_lines_count: 10, total_lines_count: 10 }); | |
| mockApi.getBranches.mockResolvedValue({ branches: ['BRANCH_A', 'BRANCH_B', 'BRANCH_C'], name_map: {} }); | |
| mockApi.getVoltageLevels.mockResolvedValue({ voltage_levels: ['VL1', 'VL2'], name_map: {} }); | |
| mockApi.getNominalVoltages.mockResolvedValue({ mapping: {}, unique_kv: [63, 225] }); | |
| mockApi.getVoltageLevelSubstations.mockResolvedValue({ mapping: {} }); | |
| mockApi.getNetworkDiagram.mockResolvedValue({ svg: '<svg></svg>', metadata: null }); | |
| mockApi.getContingencyDiagram.mockResolvedValue({ svg: '<svg></svg>', metadata: null, lines_overloaded: [] }); | |
| mockApi.runAnalysisStep1.mockResolvedValue({ can_proceed: true, lines_overloaded: ['LINE_OL1'] }); | |
| mockApi.getActionVariantDiagram.mockResolvedValue({ svg: '<svg></svg>', metadata: null }); | |
| localStorage.clear(); | |
| vi.unstubAllGlobals(); | |
| }); | |
| it('clears overloads from UI components immediately upon contingency change confirmation', async () => { | |
| await renderAndLoadStudy(); | |
| // 1. Select BRANCH_A with overloads | |
| mockApi.getContingencyDiagram.mockResolvedValueOnce({ | |
| svg: '<svg></svg>', | |
| lines_overloaded: ['OL_1', 'OL_2'] | |
| }); | |
| await pickBranch('BRANCH_A'); | |
| await triggerContingency(); | |
| // The banner echoes both overloads as soon as the N-1 diagram | |
| // fetch resolves (sticky strip rendered by SidebarSummary). | |
| await waitFor(() => { | |
| const banner = screen.getByTestId('sidebar-summary-overloads'); | |
| expect(banner.textContent).toMatch(/OL_1/); | |
| expect(banner.textContent).toMatch(/OL_2/); | |
| }, { timeout: 3000 }); | |
| // Run analysis to create state | |
| await runAnalysis(); | |
| // 2. Click the banner Clear shortcut — the confirmation dialog | |
| // appears because analysis state would otherwise be lost. | |
| await act(async () => { | |
| await userEvent.click(screen.getByTestId('sidebar-summary-clear')); | |
| }); | |
| await waitFor(() => { | |
| expect(screen.getByText('Change Contingency?')).toBeInTheDocument(); | |
| }, { timeout: 3000 }); | |
| // 3. Confirm change | |
| fireEvent.click(screen.getByText('Confirm')); | |
| // 4. VERIFY IMMEDIATE CLEAR — banner falls back to the empty | |
| // state (no contingency, no overloads), and the picker reappears. | |
| await waitFor(() => { | |
| expect(screen.queryByTestId('sidebar-summary-overloads')).not.toBeInTheDocument(); | |
| expect(screen.getByText('⚡ Select Contingency')).toBeInTheDocument(); | |
| }); | |
| }); | |
| it('clears ActionFeed overloads immediately when starting a new analysis', async () => { | |
| await renderAndLoadStudy(); | |
| // Ensure analysis finds overloads | |
| mockApi.runAnalysisStep1.mockResolvedValue({ can_proceed: true, lines_overloaded: ['OL_1'] }); | |
| await selectBranch('BRANCH_A'); | |
| await runAnalysis(); | |
| // Verify initial overloads in feed | |
| await waitFor(() => { | |
| expect(screen.getByTestId('action-feed')).toHaveAttribute('data-ol-count', '1'); | |
| }, { timeout: 3000 }); | |
| // Start new analysis. Slow it down to catch the 0 state. | |
| let resolveStep1: (val: { can_proceed: boolean; lines_overloaded: string[] }) => void; | |
| const slowStep1 = new Promise<{ can_proceed: boolean; lines_overloaded: string[] }>(resolve => { resolveStep1 = resolve; }); | |
| mockApi.runAnalysisStep1.mockReturnValue(slowStep1 as Promise<{ can_proceed: boolean; lines_overloaded: string[] }>); | |
| await userEvent.click(screen.getByText('🔍 Analyze & Suggest')); | |
| // VERIFY IMMEDIATE CLEAR in ActionFeed | |
| await waitFor(() => { | |
| expect(screen.getByTestId('action-feed')).toHaveAttribute('data-ol-count', '0'); | |
| }, { timeout: 3000 }); | |
| // Cleanup | |
| resolveStep1!({ can_proceed: true, lines_overloaded: [] }); | |
| }); | |
| it('preserves N-1 diagram in VisualizationPanel when running analysis (regression test)', async () => { | |
| await renderAndLoadStudy(); | |
| await selectBranch('BRANCH_A'); | |
| // Initially, N-1 diagram should be present | |
| await waitFor(() => { | |
| expect(screen.getByTestId('visualization-panel')).toHaveAttribute('data-n1-diagram-present', 'true'); | |
| }); | |
| // Run analysis (which used to trigger clearContingencyState and wipe the diagram) | |
| await runAnalysis(); | |
| // VERIFY: N-1 diagram is STILL present in the panel | |
| expect(screen.getByTestId('visualization-panel')).toHaveAttribute('data-n1-diagram-present', 'true'); | |
| }); | |
| it('pins contingency + overloads while only the action feed scrolls', async () => { | |
| await renderAndLoadStudy(); | |
| await selectBranch('BRANCH_A'); | |
| // The sidebar itself no longer scrolls — it hosts a non-scrolling | |
| // sticky header (contingency + overloads) and a scrolling body | |
| // (the ActionFeed). Scrolling down in actions must leave the | |
| // contingency and overload information fully visible. | |
| const sidebar = await screen.findByTestId('sidebar'); | |
| expect(sidebar).toBeInTheDocument(); | |
| expect(sidebar).toHaveStyle({ overflow: 'hidden' }); | |
| // The ActionFeed is inside a scrolling wrapper that takes the | |
| // remaining sidebar height. It only mounts after a contingency | |
| // has been committed, mirroring the new sidebar visibility gate. | |
| const sidebarActionsHeader = await within(sidebar).findByTestId('action-feed-header'); | |
| const scrollWrapper = sidebarActionsHeader.closest('div[style*="overflow-y: auto"]'); | |
| expect(scrollWrapper).toBeInTheDocument(); | |
| expect(scrollWrapper).toHaveStyle({ overflowY: 'auto' }); | |
| }); | |
| it('switches to overflow tab as soon as PDF event is received (regression test)', async () => { | |
| await renderAndLoadStudy(); | |
| await selectBranch('BRANCH_A'); | |
| // Mock a streaming response that yields a PDF event first, then delays the result | |
| let resolveStream: (value: void) => void; | |
| const streamDelay = new Promise<void>(resolve => { resolveStream = resolve; }); | |
| const mockStream = new ReadableStream({ | |
| async start(controller) { | |
| const encoder = new TextEncoder(); | |
| // 1. Send PDF event | |
| controller.enqueue(encoder.encode( | |
| JSON.stringify({ type: 'pdf', pdf_url: '/results/pdf/graph.pdf', pdf_path: '/tmp/graph.pdf' }) + '\n' | |
| )); | |
| // Wait for a bit to simulate processing delay before the final result | |
| await streamDelay; | |
| // 2. Send Result event | |
| controller.enqueue(encoder.encode( | |
| JSON.stringify({ type: 'result', actions: {}, lines_overloaded: ['LINE_OL1'], message: 'done', dc_fallback: false }) + '\n' | |
| )); | |
| controller.close(); | |
| }, | |
| }); | |
| mockApi.runAnalysisStep2Stream.mockResolvedValue({ | |
| ok: true, | |
| body: mockStream, | |
| }); | |
| // Start analysis | |
| const runBtn = screen.getByText('🔍 Analyze & Suggest'); | |
| await act(async () => { | |
| await userEvent.click(runBtn); | |
| }); | |
| // VERIFY: Tab should have switched to 'overflow' immediately after PDF event, | |
| // even though the stream is still pending (resolveStream not called yet). | |
| await waitFor(() => { | |
| expect(screen.getByTestId('visualization-panel')).toHaveAttribute('data-active-tab', 'overflow'); | |
| }, { timeout: 3000 }); | |
| // Now complete the stream | |
| await act(async () => { | |
| resolveStream(); | |
| }); | |
| // Final result should now be processed | |
| await waitFor(() => { | |
| expect(screen.queryByText(/Display.*prioritized actions/)).toBeInTheDocument(); | |
| }); | |
| }); | |
| }); | |
| // Regression: when the user selects a contingency, the N-1 diagram comes | |
| // back from the backend with `lines_overloaded` already populated. Those | |
| // overloaded lines must be picked up by the app state IMMEDIATELY — and | |
| // fed into the highlight pipeline — without waiting for the user to run | |
| // "Analyze & Suggest". The previous implementation only sourced highlight | |
| // data from `result.lines_overloaded` (set post-analysis), so the orange | |
| // halos never appeared on the freshly-loaded N-1 view. The pure | |
| // computation that selects which lines to highlight is unit-tested in | |
| // `src/utils/overloadHighlights.test.ts`. The integration assertions | |
| // here verify the data flows through App state into the panels that | |
| // drive that computation, even before any analysis has run. | |
| describe('N-1 overload state is populated before action analysis', () => { | |
| beforeEach(() => { | |
| cleanup(); | |
| vi.clearAllMocks(); | |
| Object.values(mockApi).forEach(m => { | |
| if (vi.isMockFunction(m)) m.mockReset(); | |
| }); | |
| mockApi.updateConfig.mockResolvedValue({ monitored_lines_count: 10, total_lines_count: 10 }); | |
| mockApi.getBranches.mockResolvedValue({ branches: ['BRANCH_A', 'BRANCH_B', 'BRANCH_C'], name_map: {} }); | |
| mockApi.getVoltageLevels.mockResolvedValue({ voltage_levels: ['VL1', 'VL2'], name_map: {} }); | |
| mockApi.getNominalVoltages.mockResolvedValue({ mapping: {}, unique_kv: [63, 225] }); | |
| mockApi.getVoltageLevelSubstations.mockResolvedValue({ mapping: {} }); | |
| mockApi.getNetworkDiagram.mockResolvedValue({ svg: '<svg></svg>', metadata: null }); | |
| mockApi.getContingencyDiagram.mockResolvedValue({ svg: '<svg></svg>', metadata: null, lines_overloaded: [] }); | |
| localStorage.clear(); | |
| vi.unstubAllGlobals(); | |
| }); | |
| it('exposes N-1 overloads + a default selection set as soon as the N-1 diagram loads (no analysis yet)', async () => { | |
| mockApi.getContingencyDiagram.mockResolvedValueOnce({ | |
| svg: '<svg></svg>', | |
| metadata: null, | |
| lines_overloaded: ['LINE_OL_A', 'LINE_OL_B'], | |
| }); | |
| await renderAndLoadStudy(); | |
| await selectBranch('BRANCH_A'); | |
| // The sticky banner reflects the two overloads from the N-1 | |
| // diagram fetch — and they are auto-selected (rendered as blue | |
| // dotted-underlined links, the SidebarSummary convention) so the | |
| // computeN1OverloadHighlights helper will return them as the | |
| // highlight set on the very next render of the N-1 tab. | |
| await waitFor(() => { | |
| const banner = screen.getByTestId('sidebar-summary-overloads'); | |
| expect(banner.textContent).toMatch(/LINE_OL_A/); | |
| expect(banner.textContent).toMatch(/LINE_OL_B/); | |
| }); | |
| // No analysis was run, so ``result.lines_overloaded`` is empty — | |
| // but the ActionFeed falls back to the N-1 diagram's authoritative | |
| // overload list so manual-simulation action cards display the | |
| // friendly pypowsybl identifiers (e.g. ``BEON L31CPVAN``) instead | |
| // of grid2op's synthetic ``line_<i>`` strings the backend emits | |
| // when no ``_analysis_context`` is set yet. See the App.tsx wiring | |
| // on the ``ActionFeed.linesOverloaded`` prop. | |
| expect(screen.getByTestId('action-feed')).toHaveAttribute('data-ol-count', '2'); | |
| // The fallback delivers the exact friendly-name list — same | |
| // identifiers the OverloadPanel above is wired to — so the | |
| // ``ActionCard.linesOverloaded[i]`` lookup in the "Overload | |
| // loading after" row resolves through ``displayName`` cleanly. | |
| expect(screen.getByTestId('action-feed')).toHaveAttribute( | |
| 'data-ol-names', | |
| 'LINE_OL_A|LINE_OL_B', | |
| ); | |
| }); | |
| // Companion to the test above: once an analysis result is present, | |
| // ``result.lines_overloaded`` MUST win over the ``n1Diagram`` | |
| // fallback — the step2 stream reports the resolved-and-filtered set | |
| // (post monitoring deselect / additional-lines etc), and that | |
| // authority beats the raw N-1 diagram scan. Pins both directions of | |
| // the App.tsx ternary on the ``ActionFeed.linesOverloaded`` prop. | |
| it('prefers result.lines_overloaded over the n1Diagram fallback once analysis has populated it', async () => { | |
| mockApi.getContingencyDiagram.mockResolvedValueOnce({ | |
| svg: '<svg></svg>', | |
| metadata: null, | |
| // Raw n1Diagram overload scan — what the fallback would surface | |
| // if the analysis result were empty. TWO entries. | |
| lines_overloaded: ['LINE_OL_A', 'LINE_OL_B'], | |
| }); | |
| // Step1 filters the set down to a single resolved overload. | |
| mockApi.runAnalysisStep1.mockResolvedValue({ | |
| can_proceed: true, | |
| lines_overloaded: ['LINE_OL_A'], | |
| }); | |
| await renderAndLoadStudy(); | |
| await selectBranch('BRANCH_A'); | |
| // Confirm the fallback fires pre-analysis (n1Diagram count = 2). | |
| await waitFor(() => { | |
| expect(screen.getByTestId('action-feed')).toHaveAttribute('data-ol-count', '2'); | |
| }); | |
| // Drive the full step1 → step2 stream → display-prioritized | |
| // path so ``result.lines_overloaded`` becomes non-empty. | |
| await runAnalysis(); | |
| // The analysis-result list wins — single ``LINE_OL1`` from the | |
| // stream mock above, not the two-entry n1Diagram fallback. | |
| await waitFor(() => { | |
| expect(screen.getByTestId('action-feed')).toHaveAttribute('data-ol-count', '1'); | |
| expect(screen.getByTestId('action-feed')).toHaveAttribute( | |
| 'data-ol-names', | |
| 'LINE_OL1', | |
| ); | |
| }); | |
| }); | |
| // Regression: a useEffect in App.tsx re-seeds selectedOverloads to the | |
| // full n1Diagram.lines_overloaded list when the analysis memo refreshes. | |
| // The original implementation compared against the live selectedOverloads | |
| // set, so every user toggle retriggered the effect and re-added the | |
| // overload that was just removed ("blinks but does not switch to | |
| // unselected"). Lock in the fix: deselecting an overload must persist. | |
| it('preserves user-deselected overloads across re-renders (info bubble unselect)', async () => { | |
| mockApi.getContingencyDiagram.mockResolvedValueOnce({ | |
| svg: '<svg></svg>', | |
| metadata: null, | |
| lines_overloaded: ['LINE_OL_A', 'LINE_OL_B'], | |
| }); | |
| await renderAndLoadStudy(); | |
| await selectBranch('BRANCH_A'); | |
| // Open the info bubble next to the Overloads banner label — | |
| // the popover hosts the per-overload monitoring toggles. Both | |
| // overloads start checked (auto-selected) so both checkboxes | |
| // are initially ticked. | |
| await waitFor(() => screen.getByTestId('sidebar-summary-overloads-bubble')); | |
| await act(async () => { | |
| screen.getByTestId('sidebar-summary-overloads-bubble').click(); | |
| }); | |
| const popover = await screen.findByTestId('overload-info-popover'); | |
| const toggleA = within(popover).getByTestId('overload-info-toggle-LINE_OL_A') | |
| .querySelector('input[type="checkbox"]') as HTMLInputElement; | |
| const toggleB = within(popover).getByTestId('overload-info-toggle-LINE_OL_B') | |
| .querySelector('input[type="checkbox"]') as HTMLInputElement; | |
| expect(toggleA.checked).toBe(true); | |
| expect(toggleB.checked).toBe(true); | |
| await act(async () => { toggleA.click(); }); | |
| await waitFor(() => { | |
| expect((within(screen.getByTestId('overload-info-popover')) | |
| .getByTestId('overload-info-toggle-LINE_OL_A') | |
| .querySelector('input[type="checkbox"]') as HTMLInputElement).checked).toBe(false); | |
| }); | |
| // Force an additional render cycle — the buggy effect retriggered on | |
| // every analysis-memo refresh and re-added the deselected overload. | |
| await act(async () => { | |
| (within(screen.getByTestId('overload-info-popover')) | |
| .getByTestId('overload-info-toggle-LINE_OL_B') | |
| .querySelector('input[type="checkbox"]') as HTMLInputElement).click(); | |
| }); | |
| await waitFor(() => { | |
| expect((within(screen.getByTestId('overload-info-popover')) | |
| .getByTestId('overload-info-toggle-LINE_OL_A') | |
| .querySelector('input[type="checkbox"]') as HTMLInputElement).checked).toBe(false); | |
| expect((within(screen.getByTestId('overload-info-popover')) | |
| .getByTestId('overload-info-toggle-LINE_OL_B') | |
| .querySelector('input[type="checkbox"]') as HTMLInputElement).checked).toBe(false); | |
| }); | |
| }); | |
| it('replaces the overload selection when switching contingencies without running analysis', async () => { | |
| mockApi.getContingencyDiagram.mockResolvedValueOnce({ | |
| svg: '<svg></svg>', | |
| metadata: null, | |
| lines_overloaded: ['LINE_OL_A', 'LINE_OL_B'], | |
| }); | |
| await renderAndLoadStudy(); | |
| await selectBranch('BRANCH_A'); | |
| await waitFor(() => { | |
| const banner = screen.getByTestId('sidebar-summary-overloads'); | |
| expect(banner.textContent).toMatch(/LINE_OL_A/); | |
| expect(banner.textContent).toMatch(/LINE_OL_B/); | |
| }); | |
| mockApi.getContingencyDiagram.mockResolvedValueOnce({ | |
| svg: '<svg></svg>', | |
| metadata: null, | |
| lines_overloaded: ['LINE_OL_C'], | |
| }); | |
| // pickBranch automatically routes through the Clear shortcut | |
| // (no analysis state → no confirmation), so the new contingency | |
| // is the lone BRANCH_B rather than the legacy ['A', 'B'] append. | |
| await pickBranch('BRANCH_B'); | |
| await triggerContingency(); | |
| await waitFor(() => { | |
| expect(mockApi.getContingencyDiagram).toHaveBeenCalledWith(['BRANCH_B']); | |
| const banner = screen.getByTestId('sidebar-summary-overloads'); | |
| expect(banner.textContent).toMatch(/LINE_OL_C/); | |
| expect(banner.textContent).not.toMatch(/LINE_OL_A/); | |
| expect(banner.textContent).not.toMatch(/LINE_OL_B/); | |
| }); | |
| }); | |
| }); | |