diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx new file mode 100644 index 00000000..0e6787de --- /dev/null +++ b/frontend/src/App.test.tsx @@ -0,0 +1,347 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import App from './App'; + +// ===== Mocks ===== + +// Mock child components to avoid their complexity +vi.mock('./components/VisualizationPanel', () => ({ + default: () =>
, +})); +vi.mock('./components/ActionFeed', () => ({ + default: () =>
, +})); +vi.mock('./components/OverloadPanel', () => ({ + default: () =>
, +})); + +// Mock hooks +vi.mock('./hooks/usePanZoom', () => ({ + usePanZoom: () => ({ viewBox: null, setViewBox: vi.fn() }), +})); + +// Mock SVG utilities +vi.mock('./utils/svgUtils', () => ({ + buildMetadataIndex: () => null, + applyOverloadedHighlights: vi.fn(), + applyDeltaVisuals: vi.fn(), + applyActionTargetHighlights: vi.fn(), + applyContingencyHighlight: vi.fn(), + getIdMap: () => new Map(), + invalidateIdMapCache: vi.fn(), +})); + +vi.mock('./utils/svgWorkerClient', () => ({ + processSvgAsync: vi.fn().mockResolvedValue({ svg: '', viewBox: { x: 0, y: 0, w: 100, h: 100 } }), +})); + +// 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(['BRANCH_A', 'BRANCH_B', 'BRANCH_C']), + getVoltageLevels: vi.fn().mockResolvedValue(['VL1', 'VL2']), + getNominalVoltages: vi.fn().mockResolvedValue({ mapping: {}, unique_kv: [63, 225] }), + getNetworkDiagram: vi.fn().mockResolvedValue({ svg: '', metadata: null }), + getN1Diagram: vi.fn().mockResolvedValue({ svg: '', metadata: null, lines_overloaded: [] }), + pickPath: vi.fn(), + runAnalysisStep1: vi.fn().mockResolvedValue({ can_proceed: true, lines_overloaded: ['LINE_OL1'] }), + getActionVariantDiagram: vi.fn().mockResolvedValue({ svg: '', metadata: null }), + getNSld: vi.fn(), + getN1Sld: vi.fn(), + getActionVariantSld: vi.fn(), +})); + +vi.mock('./api', () => ({ + api: mockApi, +})); + +// Helper: render App, load config, wait for branches to appear +async function renderAndLoadStudy() { + render(); + + // Click Load Study + const loadBtn = screen.getByText('🔄 Load Study'); + await act(async () => { + await userEvent.click(loadBtn); + }); + + // Wait for branches to be loaded (the datalist input appears) + await waitFor(() => { + expect(screen.getByPlaceholderText('Search line/bus...')).toBeInTheDocument(); + }); +} + +// Helper: select a valid branch by typing the full name +async function selectBranch(branchName: string) { + const input = screen.getByPlaceholderText('Search line/bus...'); + await act(async () => { + await userEvent.clear(input); + await userEvent.type(input, branchName); + }); + // Wait for N-1 diagram fetch to complete + await waitFor(() => { + expect(mockApi.getN1Diagram).toHaveBeenCalledWith(branchName); + }); +} + +// Helper: run analysis to create analysis state +async function runAnalysis() { + // Mock fetch for step2 streaming response + const mockResponse = 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(); + }, + }); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + body: mockResponse, + })); + + const runBtn = screen.getByText('🚀 Run Analysis'); + await act(async () => { + await userEvent.click(runBtn); + }); + + await waitFor(() => { + expect(screen.queryByText('⚙️ Running...')).not.toBeInTheDocument(); + }); +} + +describe('Contingency Change Confirmation', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + // Reset fetch stub + vi.unstubAllGlobals(); + }); + + it('does NOT show confirmation dialog when no analysis state exists', async () => { + await renderAndLoadStudy(); + await selectBranch('BRANCH_A'); + + // Now switch to BRANCH_B — no analysis has been run, so no dialog + mockApi.getN1Diagram.mockClear(); + const input = screen.getByPlaceholderText('Search line/bus...'); + await act(async () => { + await userEvent.clear(input); + await userEvent.type(input, 'BRANCH_B'); + }); + + await waitFor(() => { + expect(mockApi.getN1Diagram).toHaveBeenCalledWith('BRANCH_B'); + }); + + // No dialog should appear + expect(screen.queryByText('Change Contingency?')).not.toBeInTheDocument(); + }); + + it('shows confirmation dialog when switching branch after running analysis', async () => { + await renderAndLoadStudy(); + await selectBranch('BRANCH_A'); + await runAnalysis(); + + // Now try to switch to BRANCH_B + mockApi.getN1Diagram.mockClear(); + const input = screen.getByPlaceholderText('Search line/bus...'); + await act(async () => { + await userEvent.clear(input); + await userEvent.type(input, 'BRANCH_B'); + }); + + // Dialog should appear + await waitFor(() => { + expect(screen.getByText('Change Contingency?')).toBeInTheDocument(); + }); + expect(screen.getByText(/All previous analysis results/)).toBeInTheDocument(); + expect(screen.getByText(/The network state will be preserved/)).toBeInTheDocument(); + + // N-1 diagram should NOT have been fetched for BRANCH_B yet + expect(mockApi.getN1Diagram).not.toHaveBeenCalledWith('BRANCH_B'); + }); + + it('clears state and switches branch when user confirms', async () => { + await renderAndLoadStudy(); + await selectBranch('BRANCH_A'); + await runAnalysis(); + + // Switch to BRANCH_B + mockApi.getN1Diagram.mockClear(); + const input = screen.getByPlaceholderText('Search line/bus...'); + await act(async () => { + await userEvent.clear(input); + await userEvent.type(input, 'BRANCH_B'); + }); + + await waitFor(() => { + expect(screen.getByText('Change Contingency?')).toBeInTheDocument(); + }); + + // Click Confirm + await act(async () => { + await userEvent.click(screen.getByText('Confirm')); + }); + + // Dialog should close + expect(screen.queryByText('Change Contingency?')).not.toBeInTheDocument(); + + // N-1 should be fetched for the new branch + await waitFor(() => { + expect(mockApi.getN1Diagram).toHaveBeenCalledWith('BRANCH_B'); + }); + }); + + it('reverts to old branch and keeps state when user cancels', async () => { + await renderAndLoadStudy(); + await selectBranch('BRANCH_A'); + await runAnalysis(); + + // Switch to BRANCH_B + mockApi.getN1Diagram.mockClear(); + const input = screen.getByPlaceholderText('Search line/bus...'); + await act(async () => { + await userEvent.clear(input); + await userEvent.type(input, 'BRANCH_B'); + }); + + await waitFor(() => { + expect(screen.getByText('Change Contingency?')).toBeInTheDocument(); + }); + + // Click Cancel + await act(async () => { + await userEvent.click(screen.getByText('Cancel')); + }); + + // Dialog should close + expect(screen.queryByText('Change Contingency?')).not.toBeInTheDocument(); + + // N-1 should NOT have been fetched for BRANCH_B + expect(mockApi.getN1Diagram).not.toHaveBeenCalledWith('BRANCH_B'); + + // Input should revert to BRANCH_A + expect(input).toHaveValue('BRANCH_A'); + }); + + it('does not trigger dialog for partial/invalid branch text', async () => { + await renderAndLoadStudy(); + await selectBranch('BRANCH_A'); + await runAnalysis(); + + // Type partial text that doesn't match any branch + mockApi.getN1Diagram.mockClear(); + const input = screen.getByPlaceholderText('Search line/bus...'); + await act(async () => { + await userEvent.clear(input); + await userEvent.type(input, 'BRAN'); + }); + + // No dialog — partial text is not a valid branch + expect(screen.queryByText('Change Contingency?')).not.toBeInTheDocument(); + // N-1 should not be fetched for invalid branch + expect(mockApi.getN1Diagram).not.toHaveBeenCalled(); + }); +}); + +describe('Load Study Confirmation', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + vi.unstubAllGlobals(); + }); + + it('loads study directly when no analysis state exists', async () => { + render(); + + const loadBtn = screen.getByText('🔄 Load Study'); + await act(async () => { + await userEvent.click(loadBtn); + }); + + // No dialog, just loads + expect(screen.queryByText('Reload Study?')).not.toBeInTheDocument(); + await waitFor(() => { + expect(mockApi.updateConfig).toHaveBeenCalled(); + }); + }); + + it('shows confirmation dialog when clicking Load Study after running analysis', async () => { + await renderAndLoadStudy(); + await selectBranch('BRANCH_A'); + await runAnalysis(); + + mockApi.updateConfig.mockClear(); + + // Click Load Study again + const loadBtn = screen.getByText('🔄 Load Study'); + await act(async () => { + await userEvent.click(loadBtn); + }); + + // Dialog should appear + await waitFor(() => { + expect(screen.getByText('Reload Study?')).toBeInTheDocument(); + }); + expect(screen.getByText(/The network will be reloaded from scratch/)).toBeInTheDocument(); + + // Config should NOT have been called yet + expect(mockApi.updateConfig).not.toHaveBeenCalled(); + }); + + it('reloads study when user confirms Load Study dialog', async () => { + await renderAndLoadStudy(); + await selectBranch('BRANCH_A'); + await runAnalysis(); + + mockApi.updateConfig.mockClear(); + + const loadBtn = screen.getByText('🔄 Load Study'); + await act(async () => { + await userEvent.click(loadBtn); + }); + + await waitFor(() => { + expect(screen.getByText('Reload Study?')).toBeInTheDocument(); + }); + + // Click Confirm + await act(async () => { + await userEvent.click(screen.getByText('Confirm')); + }); + + // Dialog should close and config should be called + expect(screen.queryByText('Reload Study?')).not.toBeInTheDocument(); + await waitFor(() => { + expect(mockApi.updateConfig).toHaveBeenCalled(); + }); + }); + + it('keeps state when user cancels Load Study dialog', async () => { + await renderAndLoadStudy(); + await selectBranch('BRANCH_A'); + await runAnalysis(); + + mockApi.updateConfig.mockClear(); + + const loadBtn = screen.getByText('🔄 Load Study'); + await act(async () => { + await userEvent.click(loadBtn); + }); + + await waitFor(() => { + expect(screen.getByText('Reload Study?')).toBeInTheDocument(); + }); + + // Click Cancel + await act(async () => { + await userEvent.click(screen.getByText('Cancel')); + }); + + // Dialog should close, config should NOT be called + expect(screen.queryByText('Reload Study?')).not.toBeInTheDocument(); + expect(mockApi.updateConfig).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4baee768..90e86af8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -92,6 +92,7 @@ function App() { setActiveTab('n'); setVlOverlay(null); setSelectedBranch(''); + committedBranchRef.current = ''; setSelectedActionIds(new Set()); setManuallyAddedIds(new Set()); setRejectedActionIds(new Set()); @@ -233,44 +234,8 @@ function App() { lastZoomState.current = { query: '', branch: '' }; }, []); - // Handle branch selection with confirmation when analysis state exists - // Only prompt for confirmation when the user picks a valid branch from the list - const handleBranchChange = useCallback((newBranch: string) => { - if (newBranch === selectedBranch) return; - // Allow clearing the input freely - if (!newBranch) { - setSelectedBranch(''); - return; - } - // Only intercept when a valid branch is selected (not while typing partial text) - const isValidBranch = branches.includes(newBranch); - if (isValidBranch && hasAnalysisState()) { - setConfirmDialog({ type: 'contingency', pendingBranch: newBranch }); - } else { - setSelectedBranch(newBranch); - } - }, [selectedBranch, branches, hasAnalysisState]); - - // Handle Load Study with confirmation when analysis state exists - const handleLoadStudyClick = useCallback(() => { - if (hasAnalysisState()) { - setConfirmDialog({ type: 'loadStudy' }); - } else { - handleLoadConfig(); - } - }, [hasAnalysisState, handleLoadConfig]); - - // Confirm the pending action from the dialog - const handleConfirmDialog = useCallback(() => { - if (!confirmDialog) return; - if (confirmDialog.type === 'contingency') { - clearContingencyState(); - setSelectedBranch(confirmDialog.pendingBranch || ''); - } else { - handleLoadConfig(); - } - setConfirmDialog(null); - }, [confirmDialog, clearContingencyState, handleLoadConfig]); + // Ref to track the branch for which N-1 was last fetched (the "committed" branch) + const committedBranchRef = useRef(''); // ===== Config Loading ===== const handleLoadConfig = useCallback(async () => { @@ -289,6 +254,7 @@ function App() { setActiveTab('n'); setVlOverlay(null); setSelectedBranch(''); + committedBranchRef.current = ''; setInspectQuery(''); lastZoomState.current = { query: '', branch: '' }; setShowMonitoringWarning(false); // Clear warning on new config load @@ -344,6 +310,28 @@ function App() { } }, [networkPath, actionPath, minLineReconnections, minCloseCoupling, minOpenCoupling, minLineDisconnections, nPrioritizedActions, monitoringFactor, linesMonitoringPath, preExistingOverloadThreshold, ignoreReconnections, pypowsyblFastMode]); + // Handle Load Study with confirmation when analysis state exists + const handleLoadStudyClick = useCallback(() => { + if (hasAnalysisState()) { + setConfirmDialog({ type: 'loadStudy' }); + } else { + handleLoadConfig(); + } + }, [hasAnalysisState, handleLoadConfig]); + + // Confirm the pending action from the dialog + const handleConfirmDialog = useCallback(() => { + if (!confirmDialog) return; + if (confirmDialog.type === 'contingency') { + clearContingencyState(); + committedBranchRef.current = confirmDialog.pendingBranch || ''; + setSelectedBranch(confirmDialog.pendingBranch || ''); + } else { + handleLoadConfig(); + } + setConfirmDialog(null); + }, [confirmDialog, clearContingencyState, handleLoadConfig]); + const fetchBaseDiagram = async (vlCount: number) => { try { const res = await api.getNetworkDiagram(); @@ -356,15 +344,33 @@ function App() { }; // ===== N-1 Diagram Fetching ===== + // Uses committedBranchRef to detect actual branch changes vs. re-selections. + // When the user picks a new valid branch while analysis state exists, show a + // confirmation dialog instead of fetching immediately. useEffect(() => { if (!selectedBranch) { setN1Diagram(null); + // Only clear the committed branch when there's no analysis state to protect + if (!hasAnalysisState()) { + committedBranchRef.current = ''; + } return; } if (branches.length > 0 && !branches.includes(selectedBranch)) { return; } + // Valid branch selected — check if we need confirmation before switching + if (selectedBranch !== committedBranchRef.current && hasAnalysisState()) { + // Show confirmation dialog and revert the input to the committed branch + setConfirmDialog({ type: 'contingency', pendingBranch: selectedBranch }); + setSelectedBranch(committedBranchRef.current); + return; + } + + // Commit this branch and fetch the N-1 diagram + committedBranchRef.current = selectedBranch; + const fetchN1 = async () => { setN1Loading(true); setActiveTab('n-1'); @@ -380,6 +386,7 @@ function App() { } }; fetchN1(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedBranch, branches, voltageLevels.length]); // ===== Analysis ===== @@ -1303,7 +1310,7 @@ function App() { handleBranchChange(e.target.value)} + onChange={e => setSelectedBranch(e.target.value)} placeholder="Search line/bus..." style={{ width: '100%', padding: '7px 10px', border: '1px solid #ccc', borderRadius: '4px', boxSizing: 'border-box', fontSize: '0.85rem' }} /> diff --git a/standalone_interface.html b/standalone_interface.html index 05d61088..931b130d 100644 --- a/standalone_interface.html +++ b/standalone_interface.html @@ -1080,6 +1080,7 @@ setActiveTab('n'); setVlOverlay(null); setSelectedBranch(''); + committedBranchRef.current = ''; setAnalysisRan(false); setManualActionIds(new Set()); setManuallyAddedIds(new Set()); @@ -1208,6 +1209,7 @@ const lastViewStateN = useRef(null); // { zoom, pan: {x, y} } const lastViewStateN1 = useRef(null); // { zoom, pan: {x, y} } const lastZoomState = useRef({ query: '', branch: '' }); // Tracks last auto-zoom triggers + const committedBranchRef = useRef(''); // Last confirmed contingency branch // Captured viewBox to re-apply after the action diagram loads // (the tab-switch sync fires before the SVG exists, then // usePanZoom resets to the diagram's native viewBox). @@ -1239,6 +1241,7 @@ setActiveTab('n'); setVlOverlay(null); setSelectedBranch(''); + committedBranchRef.current = ''; setAnalysisRan(false); setPendingAnalysisResult(null); setManualActionIds(new Set()); @@ -1381,10 +1384,39 @@ } catch (err) { console.error('Failed to fetch diagram:', err); } }; + // Check if there is any analysis state that would be lost on contingency change + const hasAnalysisState = () => { + return !!(result || pendingAnalysisResult || selectedActionId || actionDiagram || manuallyAddedIds.size > 0 || manualActionIds.size > 0 || rejectedActionIds.size > 0); + }; + + // Clear all contingency-related analysis state (preserves network/config) + const clearContingencyState = () => { + setResult(null); + setPendingAnalysisResult(null); + setSelectedActionId(null); + setManualActionIds(new Set()); + setManuallyAddedIds(new Set()); + setRejectedActionIds(new Set()); + setActionDiagram(null); + setN1Diagram(null); + setActiveTab('n'); + setVlOverlay(null); + setAnalysisRan(false); + setError(''); + setInfoMessage(''); + setInspectQuery(''); + setDismissedSelectedWarning(false); + setDismissedRejectedWarning(false); + setDismissedRecommenderWarning(false); + lastZoomState.current = { query: '', branch: '' }; + }; + // Fetch N-1 Diagram when contingency is selected (ONLY if valid branch) + // When switching contingency with existing analysis state, prompt for confirmation. useEffect(() => { if (!selectedBranch) { setN1Diagram(null); + committedBranchRef.current = ''; return; } @@ -1393,6 +1425,20 @@ return; } + // Valid branch selected — check if we need confirmation before switching + if (selectedBranch !== committedBranchRef.current && hasAnalysisState()) { + if (!window.confirm("Changing contingency will clear all previous analysis results, manual simulations, and action selections. The network state will be preserved.\n\nDo you want to continue?")) { + // User cancelled — revert input to the committed branch + setSelectedBranch(committedBranchRef.current); + return; + } + // User confirmed — clear analysis state + clearContingencyState(); + } + + // Commit this branch and fetch the N-1 diagram + committedBranchRef.current = selectedBranch; + const fetchN1 = async () => { setN1Loading(true); setActiveTab('n-1'); // Switch immediately → shows "Generating N-1 Diagram..."