// 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 } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useSession, type RestoreContext } from './useSession';
import { interactionLogger } from '../utils/interactionLogger';
import type { SessionResult, AnalysisResult } from '../types';
// Mock the api module
const mockSaveSession = vi.fn();
const mockListSessions = vi.fn();
const mockLoadSession = vi.fn();
const mockUpdateConfig = vi.fn();
const mockGetBranches = vi.fn();
const mockGetVoltageLevels = vi.fn();
const mockGetNominalVoltages = vi.fn();
const mockGetNetworkDiagram = vi.fn().mockResolvedValue({ svg: '', metadata: null });
const mockRestoreAnalysisContext = vi.fn().mockResolvedValue({
status: 'success',
lines_we_care_about_count: 0,
computed_pairs_count: 0,
});
vi.mock('../api', () => ({
api: {
saveSession: (...args: unknown[]) => mockSaveSession(...args),
listSessions: (...args: unknown[]) => mockListSessions(...args),
loadSession: (...args: unknown[]) => mockLoadSession(...args),
updateConfig: (...args: unknown[]) => mockUpdateConfig(...args),
getBranches: (...args: unknown[]) => mockGetBranches(...args),
getVoltageLevels: (...args: unknown[]) => mockGetVoltageLevels(...args),
getNominalVoltages: (...args: unknown[]) => mockGetNominalVoltages(...args),
getNetworkDiagram: (...args: unknown[]) => mockGetNetworkDiagram(...args),
restoreAnalysisContext: (...args: unknown[]) => mockRestoreAnalysisContext(...args),
},
}));
vi.mock('../utils/sessionUtils', () => ({
buildSessionResult: vi.fn().mockReturnValue({
saved_at: '2026-03-27T12:00:00.000Z',
configuration: {},
contingency: { disconnected_elements: [], selected_overloads: [], monitor_deselected: false },
overloads: { n_overloads: [], n1_overloads: [], resolved_overloads: [] },
overflow_graph: null,
analysis: null,
interaction_log: [],
}),
}));
describe('useSession — interaction logging', () => {
beforeEach(() => {
interactionLogger.clear();
vi.clearAllMocks();
});
const makeSaveParams = (overrides: Record = {}) => ({
networkPath: '/data/net.xiidm',
actionPath: '/data/actions.json',
layoutPath: '',
outputFolderPath: '/tmp/output',
minLineReconnections: 2, minCloseCoupling: 3, minOpenCoupling: 2, minLineDisconnections: 3,
minPst: 1, minLoadShedding: 0, minRenewableCurtailmentActions: 0, nPrioritizedActions: 10,
linesMonitoringPath: '', monitoringFactor: 0.95, preExistingOverloadThreshold: 0.02,
ignoreReconnections: false, pypowsyblFastMode: true,
selectedContingency: ['LINE_A'], selectedOverloads: new Set(['OL1']), monitorDeselected: false,
nOverloads: [] as string[], n1Overloads: ['OL1'],
result: null,
selectedActionIds: new Set(), rejectedActionIds: new Set(),
manuallyAddedIds: new Set(), suggestedByRecommenderIds: new Set(),
setInfoMessage: vi.fn(), setError: vi.fn(),
...overrides,
});
it('logs session_saved when handleSaveResults is called with output folder', async () => {
mockSaveSession.mockResolvedValue({ session_folder: '/tmp/session_1', pdf_copied: false });
const { result } = renderHook(() => useSession());
await act(async () => {
await result.current.handleSaveResults(makeSaveParams());
});
const log = interactionLogger.getLog();
expect(log).toHaveLength(1);
expect(log[0].type).toBe('session_saved');
expect(log[0].details).toEqual({ output_folder: '/tmp/output' });
});
it('logs session_saved even for browser download (no output folder)', async () => {
// Render the hook FIRST before mocking document.createElement
const { result } = renderHook(() => useSession());
// Now mock DOM for download
const mockAnchor = document.createElement('a');
const clickSpy = vi.spyOn(mockAnchor, 'click').mockImplementation(() => {});
const createElementSpy = vi.spyOn(document, 'createElement').mockReturnValue(mockAnchor);
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:test');
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
await act(async () => {
await result.current.handleSaveResults(makeSaveParams({ outputFolderPath: '', selectedContingency: [] }));
});
const log = interactionLogger.getLog();
expect(log).toHaveLength(1);
expect(log[0].type).toBe('session_saved');
expect(log[0].details).toEqual({ output_folder: '' });
expect(clickSpy).toHaveBeenCalled();
createElementSpy.mockRestore();
});
it('logs session_reload_modal_opened when handleOpenReloadModal is called', async () => {
mockListSessions.mockResolvedValue({ sessions: ['session_1', 'session_2'] });
const { result } = renderHook(() => useSession());
const setError = vi.fn();
await act(async () => {
await result.current.handleOpenReloadModal('/tmp/output', setError);
});
const log = interactionLogger.getLog();
expect(log).toHaveLength(1);
expect(log[0].type).toBe('session_reload_modal_opened');
});
it('logs session_reload_modal_opened even when outputFolderPath is empty (before error)', async () => {
const { result } = renderHook(() => useSession());
const setError = vi.fn();
await act(async () => {
await result.current.handleOpenReloadModal('', setError);
});
const log = interactionLogger.getLog();
expect(log).toHaveLength(1);
expect(log[0].type).toBe('session_reload_modal_opened');
expect(setError).toHaveBeenCalled();
});
it('passes interaction_log to api.saveSession', async () => {
mockSaveSession.mockResolvedValue({ session_folder: '/tmp/s', pdf_copied: false });
// Record some interactions first
interactionLogger.record('zoom_in');
interactionLogger.record('config_loaded', { path: '/data' });
const { result } = renderHook(() => useSession());
await act(async () => {
await result.current.handleSaveResults(makeSaveParams());
});
expect(mockSaveSession).toHaveBeenCalledTimes(1);
const callArgs = mockSaveSession.mock.calls[0][0];
expect(callArgs.interaction_log).toBeDefined();
// Should be valid JSON containing our logged events
const parsed = JSON.parse(callArgs.interaction_log);
expect(Array.isArray(parsed)).toBe(true);
// At least the two we recorded plus the session_saved event
expect(parsed.length).toBeGreaterThanOrEqual(3);
});
it('handleSaveResults passes interactionLog to buildSessionResult', async () => {
mockSaveSession.mockResolvedValue({ session_folder: '/tmp/s', pdf_copied: false });
const { buildSessionResult } = await import('../utils/sessionUtils');
interactionLogger.record('zoom_in');
const { result } = renderHook(() => useSession());
await act(async () => {
await result.current.handleSaveResults(makeSaveParams({ networkPath: '', actionPath: '', selectedContingency: [] }));
});
expect(buildSessionResult).toHaveBeenCalledTimes(1);
const buildArgs = (buildSessionResult as ReturnType).mock.calls[0][0];
expect(buildArgs.interactionLog).toBeDefined();
expect(Array.isArray(buildArgs.interactionLog)).toBe(true);
});
});
// ===========================================================================
// handleRestoreSession — session reload fidelity
// ===========================================================================
//
// These tests guard the fixes that land alongside PRs #73, #78, #83 and
// #88: enrichment fields (load_shedding_details / curtailment_details /
// pst_details / lines_overloaded_after) must survive a save/reload
// round-trip, committedNetworkPathRef must be updated on restore so the
// "Change Network?" confirmation dialog doesn't misfire, and the new
// recommender thresholds (min_load_shedding,
// min_renewable_curtailment_actions) must be restored even when the
// session JSON predates them.
//
// The tests mock the api module at the top of this file; each restore
// test wires a fresh RestoreContext with vi.fn() setters so it can
// assert exactly which values were pushed into App state.
// ---------------------------------------------------------------------------
describe('useSession — handleRestoreSession', () => {
beforeEach(() => {
interactionLogger.clear();
vi.clearAllMocks();
mockUpdateConfig.mockResolvedValue({});
mockGetBranches.mockResolvedValue({ branches: ['LINE_A', 'LINE_B'], name_map: {} });
mockGetVoltageLevels.mockResolvedValue({ voltage_levels: ['VL_1', 'VL_2'], name_map: {} });
mockGetNominalVoltages.mockResolvedValue({
mapping: { VL_1: 400, VL_2: 225 },
unique_kv: [225, 400],
});
});
/** Build a minimal but valid RestoreContext with vi.fn() setters. */
const makeCtx = (
overrides: Partial = {},
): RestoreContext & {
// Expose the refs for assertions.
restoringSessionRef: { current: boolean };
committedBranchRef: { current: string };
committedNetworkPathRef: { current: string };
} => ({
outputFolderPath: '/tmp/output',
setNetworkPath: vi.fn(),
setActionPath: vi.fn(),
setLayoutPath: vi.fn(),
setMinLineReconnections: vi.fn(),
setMinCloseCoupling: vi.fn(),
setMinOpenCoupling: vi.fn(),
setMinLineDisconnections: vi.fn(),
setMinPst: vi.fn(),
setMinLoadShedding: vi.fn(),
setMinRenewableCurtailmentActions: vi.fn(),
setMinRedispatch: vi.fn(),
setAllowedActionTypes: vi.fn(),
setNPrioritizedActions: vi.fn(),
setLinesMonitoringPath: vi.fn(),
setMonitoringFactor: vi.fn(),
setPreExistingOverloadThreshold: vi.fn(),
setIgnoreReconnections: vi.fn(),
setPypowsyblFastMode: vi.fn(),
applyConfigResponse: vi.fn(),
setBranches: vi.fn(),
setVoltageLevels: vi.fn(),
setNameMap: vi.fn(),
setNominalVoltageMap: vi.fn(),
setUniqueVoltages: vi.fn(),
setVoltageRange: vi.fn(),
fetchBaseDiagram: vi.fn(),
ingestBaseDiagram: vi.fn(),
setMonitorDeselected: vi.fn(),
setSelectedOverloads: vi.fn(),
setResult: vi.fn(),
setSelectedActionIds: vi.fn(),
setRejectedActionIds: vi.fn(),
setManuallyAddedIds: vi.fn(),
setSuggestedByRecommenderIds: vi.fn(),
restoringSessionRef: { current: false },
committedBranchRef: { current: '' },
committedNetworkPathRef: { current: '' },
setSelectedContingency: vi.fn(),
setInfoMessage: vi.fn(),
setError: vi.fn(),
...overrides,
} as unknown as RestoreContext & {
restoringSessionRef: { current: boolean };
committedBranchRef: { current: string };
committedNetworkPathRef: { current: string };
});
/** Minimal SessionResult builder with sensible defaults. */
const makeSession = (overrides: Partial = {}): SessionResult => ({
saved_at: '2026-04-14T10:00:00.000Z',
configuration: {
network_path: '/data/net.xiidm',
action_file_path: '/data/actions.json',
layout_path: '/data/layout.json',
min_line_reconnections: 2.0,
min_close_coupling: 3.0,
min_open_coupling: 2.0,
min_line_disconnections: 3.0,
min_pst: 1.5,
min_load_shedding: 2.5,
min_renewable_curtailment_actions: 1.25,
min_redispatch: 3.0,
allowed_action_types: ['redispatch'],
n_prioritized_actions: 8,
lines_monitoring_path: '/data/monitoring.csv',
monitoring_factor: 0.93,
pre_existing_overload_threshold: 0.04,
ignore_reconnections: true,
pypowsybl_fast_mode: false,
},
contingency: {
disconnected_elements: ['LINE_A'],
selected_overloads: ['LINE_OL1', 'LINE_OL2'],
monitor_deselected: true,
},
overloads: {
n_overloads: [],
n1_overloads: ['LINE_OL1', 'LINE_OL2'],
resolved_overloads: ['LINE_OL1'],
},
overflow_graph: null,
analysis: null,
...overrides,
});
it('restores every configuration field, including new load-shedding / curtailment thresholds', async () => {
mockLoadSession.mockResolvedValue(makeSession());
const ctx = makeCtx();
const { result } = renderHook(() => useSession());
await act(async () => {
await result.current.handleRestoreSession('session_abc', ctx);
});
// Paths
expect(ctx.setNetworkPath).toHaveBeenCalledWith('/data/net.xiidm');
expect(ctx.setActionPath).toHaveBeenCalledWith('/data/actions.json');
expect(ctx.setLayoutPath).toHaveBeenCalledWith('/data/layout.json');
// Recommender thresholds — the two values added by PR #73 / #78
// are the specific regression this test is guarding against.
expect(ctx.setMinLineReconnections).toHaveBeenCalledWith(2.0);
expect(ctx.setMinCloseCoupling).toHaveBeenCalledWith(3.0);
expect(ctx.setMinOpenCoupling).toHaveBeenCalledWith(2.0);
expect(ctx.setMinLineDisconnections).toHaveBeenCalledWith(3.0);
expect(ctx.setMinPst).toHaveBeenCalledWith(1.5);
expect(ctx.setMinLoadShedding).toHaveBeenCalledWith(2.5);
expect(ctx.setMinRenewableCurtailmentActions).toHaveBeenCalledWith(1.25);
expect(ctx.setMinRedispatch).toHaveBeenCalledWith(3.0);
expect(ctx.setAllowedActionTypes).toHaveBeenCalledWith(['redispatch']);
expect(ctx.setNPrioritizedActions).toHaveBeenCalledWith(8);
// Monitoring + flags
expect(ctx.setLinesMonitoringPath).toHaveBeenCalledWith('/data/monitoring.csv');
expect(ctx.setMonitoringFactor).toHaveBeenCalledWith(0.93);
expect(ctx.setPreExistingOverloadThreshold).toHaveBeenCalledWith(0.04);
expect(ctx.setIgnoreReconnections).toHaveBeenCalledWith(true);
expect(ctx.setPypowsyblFastMode).toHaveBeenCalledWith(false);
});
it('falls back to safe defaults when older sessions lack load-shedding / curtailment thresholds', async () => {
// Simulate a pre-PR-#73 session dump: neither new field present.
const legacy = makeSession();
delete (legacy.configuration as { min_load_shedding?: number }).min_load_shedding;
delete (legacy.configuration as { min_renewable_curtailment_actions?: number })
.min_renewable_curtailment_actions;
mockLoadSession.mockResolvedValue(legacy);
const ctx = makeCtx();
const { result } = renderHook(() => useSession());
await act(async () => {
await result.current.handleRestoreSession('legacy_session', ctx);
});
// Defaults are 0.0 per the restore contract: "Older session
// dumps that predate these fields fall back to 0.0 on reload."
expect(ctx.setMinLoadShedding).toHaveBeenCalledWith(0.0);
expect(ctx.setMinRenewableCurtailmentActions).toHaveBeenCalledWith(0.0);
});
it('updates committedNetworkPathRef on successful restore', async () => {
// The bug: before this fix, the ref stayed empty after a
// reload, so the first manual edit to the Header network input
// silently dropped the study instead of prompting the "Change
// Network?" confirmation dialog.
mockLoadSession.mockResolvedValue(makeSession());
const ctx = makeCtx();
const { result } = renderHook(() => useSession());
await act(async () => {
await result.current.handleRestoreSession('session_xyz', ctx);
});
expect(ctx.committedNetworkPathRef.current).toBe('/data/net.xiidm');
});
it('forwards all configuration fields to the backend via api.updateConfig', async () => {
mockLoadSession.mockResolvedValue(makeSession());
const ctx = makeCtx();
const { result } = renderHook(() => useSession());
await act(async () => {
await result.current.handleRestoreSession('session_1', ctx);
});
expect(mockUpdateConfig).toHaveBeenCalledTimes(1);
const payload = mockUpdateConfig.mock.calls[0][0];
expect(payload).toMatchObject({
network_path: '/data/net.xiidm',
action_file_path: '/data/actions.json',
layout_path: '/data/layout.json',
min_line_reconnections: 2.0,
min_close_coupling: 3.0,
min_open_coupling: 2.0,
min_line_disconnections: 3.0,
min_pst: 1.5,
min_load_shedding: 2.5,
min_renewable_curtailment_actions: 1.25,
min_redispatch: 3.0,
n_prioritized_actions: 8,
monitoring_factor: 0.93,
pre_existing_overload_threshold: 0.04,
ignore_reconnections: true,
pypowsybl_fast_mode: false,
});
});
it('records a session_reloaded interaction event on success', async () => {
mockLoadSession.mockResolvedValue(makeSession());
const ctx = makeCtx();
const { result } = renderHook(() => useSession());
await act(async () => {
await result.current.handleRestoreSession('session_reload_42', ctx);
});
const log = interactionLogger.getLog();
const reloaded = log.find(e => e.type === 'session_reloaded');
expect(reloaded).toBeDefined();
expect(reloaded!.details).toEqual({ session_name: 'session_reload_42' });
});
it('short-circuits (no API call) when outputFolderPath is empty', async () => {
const ctx = makeCtx({ outputFolderPath: '' });
const { result } = renderHook(() => useSession());
await act(async () => {
await result.current.handleRestoreSession('session_x', ctx);
});
expect(mockLoadSession).not.toHaveBeenCalled();
expect(ctx.setNetworkPath).not.toHaveBeenCalled();
expect(ctx.committedNetworkPathRef.current).toBe('');
});
it('sets restoringSessionRef BEFORE the first state update so every intermediate render is flagged as a restore (regression for spurious Change Contingency? dialog on session reload with different contingency)', async () => {
// The dialog is suppressed by useContingencyFetch when
// restoringSessionRef.current === true. If the ref is only
// flipped late (after the awaits inside handleRestoreSession),
// any intermediate render that fires the
// useContingencyFetch effect with the still-old
// selectedContingency BUT a newly mutated
// committedBranchRef OR a fresh hasAnalysisState callback can
// misclassify the restore as a user-driven contingency
// change. The ref must therefore flip BEFORE any state
// mutation — i.e. before setNetworkPath, before
// setSessionRestoring's render etc.
const order: string[] = [];
const ctx = makeCtx();
const restoringRef = { current: false };
Object.defineProperty(restoringRef, 'current', {
set(v: boolean) {
if (v) order.push('restoringSessionRef=true');
else order.push('restoringSessionRef=false');
Object.defineProperty(this, '_v', { value: v, writable: true, configurable: true });
},
get() { return (this as unknown as { _v?: boolean })._v ?? false; },
configurable: true,
});
ctx.restoringSessionRef = restoringRef as unknown as typeof ctx.restoringSessionRef;
ctx.setNetworkPath = vi.fn(() => order.push('setNetworkPath'));
ctx.setSelectedContingency = vi.fn(() => order.push('setSelectedContingency'));
mockLoadSession.mockResolvedValue(makeSession({
contingency: { disconnected_elements: ['LINE_B'], selected_overloads: [], monitor_deselected: false },
}));
const { result } = renderHook(() => useSession());
await act(async () => {
await result.current.handleRestoreSession('session_early_ref', ctx);
});
const earlyRefIdx = order.indexOf('restoringSessionRef=true');
const networkPathIdx = order.indexOf('setNetworkPath');
const branchIdx = order.indexOf('setSelectedContingency');
// The ref must flip true at the very top of the function —
// before ANY ctx.setX call has had a chance to render
// intermediate state. ``setNetworkPath`` is the FIRST setter
// exercised by handleRestoreSession, so it is the canonical
// marker of "the restore has started touching state".
expect(earlyRefIdx).toBeGreaterThanOrEqual(0);
expect(networkPathIdx).toBeGreaterThanOrEqual(0);
expect(branchIdx).toBeGreaterThanOrEqual(0);
expect(earlyRefIdx).toBeLessThan(networkPathIdx);
expect(earlyRefIdx).toBeLessThan(branchIdx);
});
it('keeps restoringSessionRef true through the entire restore even when api.loadSession errors are caught (ref is cleared so the next user gesture is not misclassified)', async () => {
// Defensive: if the restore aborts mid-flight, the ref must
// be reset to false — otherwise the very next user-driven
// contingency change would silently bypass the confirmation
// dialog (because useContingencyFetch reads
// restoringSessionRef.current and would still see true).
const ctx = makeCtx();
mockLoadSession.mockRejectedValue({ response: { data: { detail: 'oops' } } });
const { result } = renderHook(() => useSession());
await act(async () => {
await result.current.handleRestoreSession('session_failing', ctx);
});
expect(ctx.restoringSessionRef.current).toBe(false);
expect(ctx.setError).toHaveBeenCalled();
});
it('also resyncs pendingContingency so the Trigger button does not stay dirty after restore (UX guard against accidental replay of the pre-restore branch)', async () => {
const ctx = makeCtx();
const setPendingContingency = vi.fn();
(ctx as RestoreContext & { setPendingContingency: typeof setPendingContingency }).setPendingContingency = setPendingContingency;
mockLoadSession.mockResolvedValue(makeSession({
contingency: { disconnected_elements: ['LINE_B'], selected_overloads: [], monitor_deselected: false },
}));
const { result } = renderHook(() => useSession());
await act(async () => {
await result.current.handleRestoreSession('session_pending_sync', ctx);
});
expect(setPendingContingency).toHaveBeenCalledWith(['LINE_B']);
});
it('calls resetAllState BEFORE api.loadSession so the dialog guard cannot race the restore', async () => {
// The user-requested fix: clean the existing per-study state
// at the START of the restore, exactly like a Load Study,
// so ``hasAnalysisState()`` reads false during the entire
// window between resetAllState and setResult(restored). That
// window is what used to make the contingency-change dialog
// fire and wipe the just-restored suggestions on Confirm.
const order: string[] = [];
const ctx = makeCtx();
const resetAllState = vi.fn(() => order.push('resetAllState'));
(ctx as RestoreContext & { resetAllState: typeof resetAllState }).resetAllState = resetAllState;
mockLoadSession.mockImplementation(async () => {
order.push('loadSession');
return makeSession({
contingency: { disconnected_elements: ['LINE_B'], selected_overloads: [], monitor_deselected: false },
});
});
const { result } = renderHook(() => useSession());
await act(async () => {
await result.current.handleRestoreSession('session_reset', ctx);
});
expect(resetAllState).toHaveBeenCalledTimes(1);
const resetIdx = order.indexOf('resetAllState');
const loadIdx = order.indexOf('loadSession');
expect(resetIdx).toBeGreaterThanOrEqual(0);
expect(loadIdx).toBeGreaterThanOrEqual(0);
expect(resetIdx).toBeLessThan(loadIdx);
});
it('on a SECOND consecutive reload, resetAllState fires first and ends with committedBranchRef matching the newly-restored contingency (regression for "Change Contingency?" appearing after successive reloads)', async () => {
// Pre-populate the refs as if a previous restore landed
// ``LINE_A`` with non-null analysis state. The new reload then
// restores ``LINE_B``. Without resetAllState clearing the refs
// first, the dialog guard in useContingencyFetch sees committed
// != selected with hasAnalysisState() true during the
// intermediate window and fires the spurious dialog.
const order: string[] = [];
const ctx = makeCtx();
const restoringRef = { current: false };
ctx.restoringSessionRef = restoringRef as unknown as typeof ctx.restoringSessionRef;
const committedBranchRef = { current: ['LINE_A'] as string[] };
Object.defineProperty(committedBranchRef, 'current', {
set(v: string[]) {
order.push(`committedBranchRef=${(v ?? []).join('+')}`);
Object.defineProperty(this, '_v', { value: v, writable: true, configurable: true });
},
get() { return (this as unknown as { _v?: string[] })._v ?? ['LINE_A']; },
configurable: true,
});
ctx.committedBranchRef = committedBranchRef as unknown as typeof ctx.committedBranchRef;
const resetAllState = vi.fn(() => {
order.push('resetAllState');
// Mirror what App.resetAllState actually does to the ref —
// wipe to empty BEFORE the restore continues.
committedBranchRef.current = [];
});
(ctx as RestoreContext & { resetAllState: typeof resetAllState }).resetAllState = resetAllState;
ctx.setSelectedContingency = vi.fn(() => order.push('setSelectedContingency'));
ctx.setResult = vi.fn(() => order.push('setResult'));
mockLoadSession.mockResolvedValue(makeSession({
contingency: { disconnected_elements: ['LINE_B'], selected_overloads: [], monitor_deselected: false },
}));
const { result } = renderHook(() => useSession());
await act(async () => {
await result.current.handleRestoreSession('session_double_reload', ctx);
});
// resetAllState must fire FIRST, wiping committedBranchRef
// from the previous reload's LINE_A.
const resetIdx = order.indexOf('resetAllState');
const wipeIdx = order.indexOf('committedBranchRef=');
const setLineBIdx = order.indexOf('committedBranchRef=LINE_B');
expect(resetIdx).toBe(0);
expect(wipeIdx).toBeGreaterThan(resetIdx);
expect(wipeIdx).toBeLessThan(setLineBIdx);
// Final state: committedBranchRef matches the restored
// contingency (LINE_B), so the dialog guard's sameElements
// check evaluates true on the post-restore render.
expect(committedBranchRef.current).toEqual(['LINE_B']);
// restoringSessionRef must be set to true between resetAllState
// and the restore's setX calls — see the dedicated test below
// for the strict ordering. Here we only assert it was flipped.
expect(restoringRef.current).toBe(true);
});
it('sets restoringSessionRef BEFORE setSelectedContingency so the N-1 fetch effect bypasses its hasAnalysisState short-circuit (regression)', async () => {
// Without the ref being flipped to `true` first, the N-1
// useEffect in App.tsx short-circuits the second a session
// with existing analysis state is restored — because the
// restored `result` is non-null so `hasAnalysisState() ===
// true`, which the early-return gate used to treat as "no
// fetch needed". The ref lets the gate distinguish
// "already-analyzed this branch" from "session just
// restored, still need N-1 diagram". Verified here via the
// call-order of the ref-set vs the setSelectedContingency call.
const order: string[] = [];
const ctx = makeCtx();
const restoringRef = { current: false };
Object.defineProperty(restoringRef, 'current', {
set(v: boolean) {
if (v) order.push('restoringSessionRef=true');
Object.defineProperty(this, '_v', { value: v, writable: true, configurable: true });
},
get() { return (this as unknown as { _v?: boolean })._v ?? false; },
configurable: true,
});
ctx.restoringSessionRef = restoringRef as unknown as typeof ctx.restoringSessionRef;
const committedBranchRef = { current: [] as string[] };
Object.defineProperty(committedBranchRef, 'current', {
set(v: string[]) {
order.push(`committedBranchRef=${(v ?? []).join('+')}`);
Object.defineProperty(this, '_v', { value: v, writable: true, configurable: true });
},
get() { return (this as unknown as { _v?: string[] })._v ?? []; },
configurable: true,
});
ctx.committedBranchRef = committedBranchRef as unknown as typeof ctx.committedBranchRef;
ctx.setSelectedContingency = vi.fn(() => order.push('setSelectedContingency'));
mockLoadSession.mockResolvedValue(makeSession({
contingency: { disconnected_elements: ['LINE_A'], selected_overloads: [], monitor_deselected: false },
}));
const { result } = renderHook(() => useSession());
await act(async () => {
await result.current.handleRestoreSession('session_ref_order', ctx);
});
// The ref must flip to true before setSelectedContingency is called,
// otherwise the N-1 effect sees `restoringSessionRef.current === false`
// and wipes the just-restored analysis state.
const refIdx = order.indexOf('restoringSessionRef=true');
const branchIdx = order.indexOf('setSelectedContingency');
expect(refIdx).toBeGreaterThanOrEqual(0);
expect(branchIdx).toBeGreaterThanOrEqual(0);
expect(refIdx).toBeLessThan(branchIdx);
expect(committedBranchRef.current).toEqual(['LINE_A']);
});
it('surfaces backend errors via ctx.setError and leaves the ref empty', async () => {
mockLoadSession.mockRejectedValue({ response: { data: { detail: 'not found' } } });
const ctx = makeCtx();
const { result } = renderHook(() => useSession());
await act(async () => {
await result.current.handleRestoreSession('missing', ctx);
});
expect(ctx.setError).toHaveBeenCalledWith(expect.stringContaining('not found'));
// Ref must remain untouched so the Header dialog logic still
// uses the pre-restore value (empty here → no false-positive).
expect(ctx.committedNetworkPathRef.current).toBe('');
});
// -----------------------------------------------------------------
// Action-enrichment restoration (PRs #73, #78, #83)
// -----------------------------------------------------------------
/**
* Extract the ActionDetail object the hook pushed into setResult
* by replaying the setter function against a null previous state.
* setResult is called with a functional updater, so we reach into
* the first call and invoke the updater to materialise the new
* AnalysisResult the hook computed.
*/
const captureRestoredResult = (
setResultMock: ReturnType,
): AnalysisResult | null => {
const call = setResultMock.mock.calls.find(args =>
typeof args[0] === 'function' || (args[0] && typeof args[0] === 'object'),
);
if (!call) return null;
const updater = call[0];
if (typeof updater === 'function') {
return (updater as (prev: AnalysisResult | null) => AnalysisResult | null)(null);
}
return updater as AnalysisResult | null;
};
it('restores load_shedding_details / curtailment_details / pst_details / lines_overloaded_after into each ActionDetail', async () => {
// This is the regression for the main session-reload bug: the
// four enrichment fields were persisted by buildSessionResult
// but dropped on reload, so the PST / load-shedding /
// curtailment editor cards rendered empty and the Remedial
// Action tab lost its post-action overload halos.
mockLoadSession.mockResolvedValue(makeSession({
analysis: {
message: 'ok',
dc_fallback: false,
action_scores: {},
combined_actions: {},
actions: {
load_shed_1: {
description_unitaire: 'Shed load L1',
rho_before: [1.1],
rho_after: [0.8],
max_rho: 0.8,
max_rho_line: 'LINE_OL1',
is_rho_reduction: true,
lines_overloaded_after: ['LINE_OL2'],
load_shedding_details: [
{ load_name: 'L1', voltage_level_id: 'VL_1', shedded_mw: 4.2 },
],
status: {
is_selected: false,
is_suggested: true,
is_rejected: false,
is_manually_simulated: false,
},
},
curtail_1: {
description_unitaire: 'Curtail wind W1',
rho_before: [1.2],
rho_after: [0.95],
max_rho: 0.95,
max_rho_line: 'LINE_OL1',
is_rho_reduction: true,
lines_overloaded_after: [],
curtailment_details: [
{ gen_name: 'W1', voltage_level_id: 'VL_2', curtailed_mw: 7.5 },
],
status: {
is_selected: false,
is_suggested: true,
is_rejected: false,
is_manually_simulated: false,
},
},
pst_1: {
description_unitaire: 'Move PST tap',
rho_before: [1.05],
rho_after: [0.9],
max_rho: 0.9,
max_rho_line: 'LINE_OL1',
is_rho_reduction: true,
pst_details: [
{ pst_name: 'PST_A', tap_position: 5, low_tap: -16, high_tap: 16 },
],
status: {
is_selected: false,
is_suggested: true,
is_rejected: false,
is_manually_simulated: false,
},
},
redispatch_1: {
description_unitaire: 'Redispatch GEN_A',
rho_before: [1.1],
rho_after: [0.85],
max_rho: 0.85,
max_rho_line: 'LINE_OL1',
is_rho_reduction: true,
redispatch_details: [
{
gen_name: 'GEN_A', voltage_level_id: 'VL_3',
delta_mw: 12.5, target_mw: 112.5, direction: 'up',
current_mw: 100.0, max_raise_mw: 40.0, max_lower_mw: 60.0,
},
],
status: {
is_selected: false,
is_suggested: true,
is_rejected: false,
is_manually_simulated: false,
},
},
},
},
}));
const ctx = makeCtx();
const { result } = renderHook(() => useSession());
await act(async () => {
await result.current.handleRestoreSession('session_enriched', ctx);
});
const restored = captureRestoredResult(ctx.setResult as ReturnType);
expect(restored).not.toBeNull();
// Load shedding
const ls = restored!.actions['load_shed_1'];
expect(ls.load_shedding_details).toEqual([
{ load_name: 'L1', voltage_level_id: 'VL_1', shedded_mw: 4.2 },
]);
expect(ls.lines_overloaded_after).toEqual(['LINE_OL2']);
// Curtailment
const ct = restored!.actions['curtail_1'];
expect(ct.curtailment_details).toEqual([
{ gen_name: 'W1', voltage_level_id: 'VL_2', curtailed_mw: 7.5 },
]);
expect(ct.lines_overloaded_after).toEqual([]);
// PST editor card depends on pst_details
const pst = restored!.actions['pst_1'];
expect(pst.pst_details).toEqual([
{ pst_name: 'PST_A', tap_position: 5, low_tap: -16, high_tap: 16 },
]);
// Redispatch editor card depends on redispatch_details (headroom bounds)
const redispatch = restored!.actions['redispatch_1'];
expect(redispatch.redispatch_details).toEqual([
{
gen_name: 'GEN_A', voltage_level_id: 'VL_3',
delta_mw: 12.5, target_mw: 112.5, direction: 'up',
current_mw: 100.0, max_raise_mw: 40.0, max_lower_mw: 60.0,
},
]);
});
it('preserves action status flags (selected / suggested / rejected / manually-simulated) on restore', async () => {
mockLoadSession.mockResolvedValue(makeSession({
analysis: {
message: 'ok',
dc_fallback: false,
action_scores: {},
combined_actions: {},
actions: {
act_fav: {
description_unitaire: 'fav',
rho_before: [1.1], rho_after: [0.8], max_rho: 0.8,
max_rho_line: 'L1', is_rho_reduction: true,
status: { is_selected: true, is_suggested: true, is_rejected: false, is_manually_simulated: false },
},
act_rej: {
description_unitaire: 'rej',
rho_before: [1.1], rho_after: [1.0], max_rho: 1.0,
max_rho_line: 'L1', is_rho_reduction: false,
status: { is_selected: false, is_suggested: true, is_rejected: true, is_manually_simulated: false },
},
act_manual: {
description_unitaire: 'manual',
rho_before: [1.2], rho_after: [0.9], max_rho: 0.9,
max_rho_line: 'L1', is_rho_reduction: true,
status: { is_selected: true, is_suggested: false, is_rejected: false, is_manually_simulated: true },
},
},
},
}));
const ctx = makeCtx();
const { result } = renderHook(() => useSession());
await act(async () => {
await result.current.handleRestoreSession('session_status', ctx);
});
expect(ctx.setSelectedActionIds).toHaveBeenCalledWith(
new Set(['act_fav', 'act_manual']),
);
expect(ctx.setRejectedActionIds).toHaveBeenCalledWith(new Set(['act_rej']));
expect(ctx.setManuallyAddedIds).toHaveBeenCalledWith(new Set(['act_manual']));
expect(ctx.setSuggestedByRecommenderIds).toHaveBeenCalledWith(
new Set(['act_fav', 'act_rej']),
);
});
it('re-pushes lines_we_care_about to the backend via /api/restore-analysis-context (regression)', async () => {
// Without this call, a simulate-action triggered after reload
// silently falls back to the backend default monitored-line
// set instead of the per-study set captured at save time.
// Parity with the standalone HTML mirror (PR
// `claude/auto-generate-standalone-interface-Hhogk`).
mockLoadSession.mockResolvedValue(makeSession({
overloads: {
n_overloads: [],
n1_overloads: ['LINE_OL1'],
resolved_overloads: ['LINE_OL1'],
},
analysis: {
message: 'ok',
dc_fallback: false,
action_scores: {},
combined_actions: {},
actions: {},
lines_we_care_about: ['LINE_MON1', 'LINE_MON2', 'LINE_MON3'],
computed_pairs: { 'act_a+act_b': { max_rho: 0.87 } },
},
}));
const ctx = makeCtx();
const { result } = renderHook(() => useSession());
await act(async () => {
await result.current.handleRestoreSession('session_with_context', ctx);
});
expect(mockRestoreAnalysisContext).toHaveBeenCalledOnce();
expect(mockRestoreAnalysisContext).toHaveBeenCalledWith({
lines_we_care_about: ['LINE_MON1', 'LINE_MON2', 'LINE_MON3'],
disconnected_elements: ['LINE_A'],
lines_overloaded: ['LINE_OL1'],
computed_pairs: { 'act_a+act_b': { max_rho: 0.87 } },
});
});
it('skips /api/restore-analysis-context when the saved session predates lines_we_care_about', async () => {
// Older session dumps never persisted the monitored-line set.
// The restore path must stay silent rather than call the
// endpoint with null payloads (which would wipe whatever
// default the backend already has).
mockLoadSession.mockResolvedValue(makeSession({
analysis: {
message: 'ok',
dc_fallback: false,
action_scores: {},
combined_actions: {},
actions: {},
// no lines_we_care_about / computed_pairs
},
}));
const ctx = makeCtx();
const { result } = renderHook(() => useSession());
await act(async () => {
await result.current.handleRestoreSession('legacy_session', ctx);
});
expect(mockRestoreAnalysisContext).not.toHaveBeenCalled();
});
it('swallows /api/restore-analysis-context failures without aborting the reload', async () => {
// If the context push fails (e.g. backend was restarted since
// save), the reload must still complete — the user simply
// loses the monitored-line set, but the rest of the session
// state is preserved. Mirrors the standalone try/catch at
// standalone_interface.html:3866.
mockLoadSession.mockResolvedValue(makeSession({
analysis: {
message: 'ok',
dc_fallback: false,
action_scores: {},
combined_actions: {},
actions: {},
lines_we_care_about: ['LINE_MON1'],
},
}));
mockRestoreAnalysisContext.mockRejectedValueOnce(new Error('backend offline'));
const ctx = makeCtx();
const { result } = renderHook(() => useSession());
await act(async () => {
await result.current.handleRestoreSession('session_crash_context', ctx);
});
expect(mockRestoreAnalysisContext).toHaveBeenCalledOnce();
// Contingency setters still ran → reload did not abort.
expect(ctx.setMonitorDeselected).toHaveBeenCalled();
expect(ctx.setSelectedOverloads).toHaveBeenCalled();
});
it('does not crash when action entries omit the new enrichment fields (legacy shape)', async () => {
// Pre-PR-#73 action entries lack every enrichment field. The
// restore path must propagate the absent fields as undefined
// instead of throwing.
mockLoadSession.mockResolvedValue(makeSession({
analysis: {
message: 'ok',
dc_fallback: false,
action_scores: {},
combined_actions: {},
actions: {
legacy_act: {
description_unitaire: 'legacy',
rho_before: [1.1], rho_after: [0.7], max_rho: 0.7,
max_rho_line: 'L1', is_rho_reduction: true,
status: { is_selected: false, is_suggested: true, is_rejected: false, is_manually_simulated: false },
},
},
},
}));
const ctx = makeCtx();
const { result } = renderHook(() => useSession());
await act(async () => {
await result.current.handleRestoreSession('legacy_action', ctx);
});
const restored = captureRestoredResult(ctx.setResult as ReturnType);
expect(restored).not.toBeNull();
const legacy = restored!.actions['legacy_act'];
expect(legacy.load_shedding_details).toBeUndefined();
expect(legacy.curtailment_details).toBeUndefined();
expect(legacy.pst_details).toBeUndefined();
expect(legacy.lines_overloaded_after).toBeUndefined();
});
it('skips estimation-only combined entries that were never simulated manually', async () => {
// Combined entries that are estimation-only (e.g. "act_a+act_b"
// with is_estimated=true and no manual simulation) are not
// restored as top-level actions — they live under
// combined_actions instead.
mockLoadSession.mockResolvedValue(makeSession({
analysis: {
message: 'ok',
dc_fallback: false,
action_scores: {},
combined_actions: {
'act_a+act_b': {
action1_id: 'act_a',
action2_id: 'act_b',
betas: [0.5, 0.5],
max_rho: 0.9,
max_rho_line: 'LINE_X',
is_rho_reduction: true,
description: 'Combined a+b',
estimated_max_rho: 0.9,
estimated_max_rho_line: 'LINE_X',
is_simulated: false,
},
},
actions: {
act_a: {
description_unitaire: 'a',
rho_before: [1.0], rho_after: [0.95], max_rho: 0.95,
max_rho_line: 'L1', is_rho_reduction: true,
status: { is_selected: false, is_suggested: true, is_rejected: false, is_manually_simulated: false },
},
'act_a+act_b': {
description_unitaire: 'a+b estimated',
rho_before: null,
rho_after: null,
max_rho: null,
max_rho_line: 'LINE_X',
is_rho_reduction: true,
is_estimated: true,
status: { is_selected: false, is_suggested: false, is_rejected: false, is_manually_simulated: false },
},
},
},
}));
const ctx = makeCtx();
const { result } = renderHook(() => useSession());
await act(async () => {
await result.current.handleRestoreSession('combo_session', ctx);
});
const restored = captureRestoredResult(ctx.setResult as ReturnType);
expect(restored).not.toBeNull();
// Only the real action survived — the estimation-only
// combined entry was filtered out.
expect(Object.keys(restored!.actions)).toEqual(['act_a']);
// But the combined_actions dictionary still carries it.
expect(restored!.combined_actions).toHaveProperty('act_a+act_b');
});
// -----------------------------------------------------------------
// Recommender model + action origin restore fidelity
// -----------------------------------------------------------------
it('restores result.active_model + compute_overflow_graph so the model reminder survives a reload', async () => {
// Regression: active_model was persisted under
// analysis.active_model but never re-attached to the live
// `result`, so the "Suggestions produced by " reminder
// below the Suggested Actions tab header vanished on reload.
mockLoadSession.mockResolvedValue(makeSession({
analysis: {
message: 'ok',
dc_fallback: false,
action_scores: {},
combined_actions: {},
active_model: 'random_overflow',
compute_overflow_graph: true,
actions: {},
},
}));
const ctx = makeCtx();
const { result } = renderHook(() => useSession());
await act(async () => {
await result.current.handleRestoreSession('model_session', ctx);
});
const restored = captureRestoredResult(ctx.setResult as ReturnType);
expect(restored).not.toBeNull();
expect(restored!.active_model).toBe('random_overflow');
expect(restored!.compute_overflow_graph).toBe(true);
});
it('restores all per-stage execution-time fields onto the live result', async () => {
// The ActionFeed reminder shows a "Suggestions produced by
// in s ⓘ" headline driven by ``result.*_time`` and
// ``result.wall_clock_time``. Reload must re-attach all six
// fields onto the live result so the operator sees the same
// numbers without re-running the analysis.
mockLoadSession.mockResolvedValue(makeSession({
analysis: {
message: 'ok',
dc_fallback: false,
action_scores: {},
combined_actions: {},
active_model: 'expert',
actions: {},
step1_time: 2.16,
overflow_graph_time: 7.84,
action_prediction_time: 0.94,
assessment_time: 15.5,
enrichment_time: 0.76,
wall_clock_time: 27.8,
},
}));
const ctx = makeCtx();
const { result } = renderHook(() => useSession());
await act(async () => {
await result.current.handleRestoreSession('timing_session', ctx);
});
const restored = captureRestoredResult(ctx.setResult as ReturnType);
expect(restored).not.toBeNull();
expect(restored!.step1_time).toBe(2.16);
expect(restored!.overflow_graph_time).toBe(7.84);
expect(restored!.action_prediction_time).toBe(0.94);
expect(restored!.assessment_time).toBe(15.5);
expect(restored!.enrichment_time).toBe(0.76);
expect(restored!.wall_clock_time).toBe(27.8);
});
it('restores timing fields as undefined when the saved session predates them', async () => {
// Legacy session dumps (before the execution-time breakdown
// landed) have no timing fields. The restore must NOT crash
// and must leave the live result without those fields — the
// ActionFeed reminder's ``showBreakdown`` falsey-check then
// hides the headline entirely.
mockLoadSession.mockResolvedValue(makeSession({
analysis: {
message: 'ok',
dc_fallback: false,
action_scores: {},
combined_actions: {},
active_model: 'expert',
actions: {},
// No timing fields whatsoever — old format.
},
}));
const ctx = makeCtx();
const { result } = renderHook(() => useSession());
await act(async () => {
await result.current.handleRestoreSession('legacy_no_timings', ctx);
});
const restored = captureRestoredResult(ctx.setResult as ReturnType);
expect(restored).not.toBeNull();
expect(restored!.step1_time).toBeUndefined();
expect(restored!.overflow_graph_time).toBeUndefined();
expect(restored!.action_prediction_time).toBeUndefined();
expect(restored!.assessment_time).toBeUndefined();
expect(restored!.enrichment_time).toBeUndefined();
expect(restored!.wall_clock_time).toBeUndefined();
});
it('treats a null overflow_graph_time as "model did not compute it" rather than missing', async () => {
// Distinct from undefined: ``null`` means a graph-free model
// (e.g. ``random``) ran. The restore should preserve that
// semantic by leaving ``overflow_graph_time`` undefined — the
// ActionFeed reminder's tooltip then omits the Overflow line.
mockLoadSession.mockResolvedValue(makeSession({
analysis: {
message: 'ok',
dc_fallback: false,
action_scores: {},
combined_actions: {},
active_model: 'random',
actions: {},
overflow_graph_time: null,
action_prediction_time: 0.5,
assessment_time: 1.2,
},
}));
const ctx = makeCtx();
const { result } = renderHook(() => useSession());
await act(async () => {
await result.current.handleRestoreSession('graphless_session', ctx);
});
const restored = captureRestoredResult(ctx.setResult as ReturnType);
expect(restored).not.toBeNull();
expect(restored!.overflow_graph_time).toBeUndefined();
expect(restored!.action_prediction_time).toBe(0.5);
expect(restored!.assessment_time).toBe(1.2);
});
it('restores a saved action origin verbatim', async () => {
mockLoadSession.mockResolvedValue(makeSession({
analysis: {
message: 'ok',
dc_fallback: false,
action_scores: {},
combined_actions: {},
active_model: 'random_overflow',
actions: {
reco_1: {
description_unitaire: 'Reco', rho_before: [1.1], rho_after: [0.9],
max_rho: 0.9, max_rho_line: 'LINE_OL1', is_rho_reduction: true,
origin: 'random_overflow',
status: { is_selected: false, is_suggested: true, is_rejected: false, is_manually_simulated: false },
},
manual_1: {
description_unitaire: 'Manual', rho_before: [1.2], rho_after: [0.85],
max_rho: 0.85, max_rho_line: 'LINE_OL1', is_rho_reduction: true,
origin: 'user',
status: { is_selected: true, is_suggested: false, is_rejected: false, is_manually_simulated: true },
},
},
},
}));
const ctx = makeCtx();
const { result } = renderHook(() => useSession());
await act(async () => {
await result.current.handleRestoreSession('origin_session', ctx);
});
const restored = captureRestoredResult(ctx.setResult as ReturnType);
expect(restored!.actions['reco_1'].origin).toBe('random_overflow');
expect(restored!.actions['manual_1'].origin).toBe('user');
});
it('derives origin for legacy sessions that predate the field (from status flags + active_model)', async () => {
// Legacy session dumps have no `origin` on action entries. The
// restore path derives one: manually-simulated → "user",
// suggested → analysis.active_model, otherwise undefined.
mockLoadSession.mockResolvedValue(makeSession({
analysis: {
message: 'ok',
dc_fallback: false,
action_scores: {},
combined_actions: {},
active_model: 'expert',
actions: {
suggested_legacy: {
description_unitaire: 'Suggested', rho_before: [1.1], rho_after: [0.9],
max_rho: 0.9, max_rho_line: 'LINE_OL1', is_rho_reduction: true,
// no `origin` — legacy shape
status: { is_selected: false, is_suggested: true, is_rejected: false, is_manually_simulated: false },
},
manual_legacy: {
description_unitaire: 'Manual', rho_before: [1.2], rho_after: [0.85],
max_rho: 0.85, max_rho_line: 'LINE_OL1', is_rho_reduction: true,
status: { is_selected: true, is_suggested: false, is_rejected: false, is_manually_simulated: true },
},
},
},
}));
const ctx = makeCtx();
const { result } = renderHook(() => useSession());
await act(async () => {
await result.current.handleRestoreSession('legacy_origin_session', ctx);
});
const restored = captureRestoredResult(ctx.setResult as ReturnType);
// suggested → derived from analysis.active_model
expect(restored!.actions['suggested_legacy'].origin).toBe('expert');
// manually-simulated → "user"
expect(restored!.actions['manual_legacy'].origin).toBe('user');
});
});