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..."