// 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 { useAnalysis } from './useAnalysis'; import { interactionLogger } from '../utils/interactionLogger'; import { notifications, DEFAULT_TIMEOUT_MS, type NotificationSeverity } from '../utils/notifications'; /** Messages currently in the notification store for a given severity. */ function toastMessages(severity: NotificationSeverity): string[] { return notifications.getSnapshot().filter(n => n.severity === severity).map(n => n.message); } // Mock the api module (dynamic import in useAnalysis) const mockRunAnalysisStep1 = vi.fn(); const mockRunAnalysisStep2Stream = vi.fn(); vi.mock('../api', () => ({ api: { runAnalysisStep1: (...args: unknown[]) => mockRunAnalysisStep1(...args), runAnalysisStep2Stream: (...args: unknown[]) => mockRunAnalysisStep2Stream(...args), }, })); /** Helper: build a ReadableStream from an NDJSON string */ function makeStream(ndjson: string): ReadableStream { const encoder = new TextEncoder(); return new ReadableStream({ start(controller) { controller.enqueue(encoder.encode(ndjson)); controller.close(); }, }); } describe('useAnalysis', () => { beforeEach(() => { vi.clearAllMocks(); interactionLogger.clear(); notifications.clear(); }); it('initializes with null result and no loading', () => { const { result } = renderHook(() => useAnalysis()); expect(result.current.result).toBeNull(); expect(result.current.analysisLoading).toBe(false); expect(notifications.getSnapshot()).toHaveLength(0); }); it('does nothing when selectedBranch is empty', async () => { const { result } = renderHook(() => useAnalysis()); const clear = vi.fn(); const setSuggested = vi.fn(); await act(async () => { await result.current.handleRunAnalysis([], clear, setSuggested); }); expect(clear).not.toHaveBeenCalled(); expect(mockRunAnalysisStep1).not.toHaveBeenCalled(); }); it('sets error when step1 returns can_proceed=false', async () => { mockRunAnalysisStep1.mockResolvedValue({ can_proceed: false, message: 'Network not loaded', lines_overloaded: [], }); const { result } = renderHook(() => useAnalysis()); const clear = vi.fn(); const setSuggested = vi.fn(); await act(async () => { await result.current.handleRunAnalysis(['LINE_X'], clear, setSuggested); }); expect(toastMessages('error')).toContain('Network not loaded'); expect(result.current.analysisLoading).toBe(false); expect(mockRunAnalysisStep2Stream).not.toHaveBeenCalled(); }); it('sets info message when no overloads detected', async () => { mockRunAnalysisStep1.mockResolvedValue({ can_proceed: true, message: 'No overloads detected', lines_overloaded: [], }); const { result } = renderHook(() => useAnalysis()); await act(async () => { await result.current.handleRunAnalysis(['LINE_X'], vi.fn(), vi.fn()); }); expect(toastMessages('info')).toContain('No overloads detected'); expect(result.current.analysisLoading).toBe(false); }); it('calls runAnalysisStep2Stream with all_overloads and monitor_deselected', async () => { const detected = ['LINE_A', 'LINE_B']; mockRunAnalysisStep1.mockResolvedValue({ can_proceed: true, message: '', lines_overloaded: detected, }); const resultEvent = JSON.stringify({ type: 'result', actions: {}, lines_overloaded: detected, message: 'Done', dc_fallback: false, }); const stream = makeStream(`${resultEvent}\n`); mockRunAnalysisStep2Stream.mockResolvedValue({ ok: true, body: stream, }); const { result } = renderHook(() => useAnalysis()); await act(async () => { await result.current.handleRunAnalysis(['LINE_X'], vi.fn(), vi.fn()); }); // Verify step2 was called with correct params expect(mockRunAnalysisStep2Stream).toHaveBeenCalledWith({ selected_overloads: detected, all_overloads: detected, monitor_deselected: false, additional_lines_to_cut: [], }, expect.any(AbortSignal)); }); it('passes monitor_deselected=true when enabled', async () => { const detected = ['LINE_A']; mockRunAnalysisStep1.mockResolvedValue({ can_proceed: true, message: '', lines_overloaded: detected, }); const stream = makeStream( JSON.stringify({ type: 'result', actions: {}, lines_overloaded: detected, message: '', dc_fallback: false }) + '\n', ); mockRunAnalysisStep2Stream.mockResolvedValue({ ok: true, body: stream }); const { result } = renderHook(() => useAnalysis()); // Enable monitor_deselected act(() => { result.current.setMonitorDeselected(true); }); await act(async () => { await result.current.handleRunAnalysis(['LINE_X'], vi.fn(), vi.fn()); }); expect(mockRunAnalysisStep2Stream).toHaveBeenCalledWith( expect.objectContaining({ monitor_deselected: true }), expect.any(AbortSignal), ); }); describe('handleToggleAdditionalLineToCut', () => { it('adds a line when toggling on, removes it when toggling off', () => { const { result } = renderHook(() => useAnalysis()); expect(result.current.additionalLinesToCut.size).toBe(0); act(() => { result.current.handleToggleAdditionalLineToCut('LINE_X'); }); expect(result.current.additionalLinesToCut.has('LINE_X')).toBe(true); act(() => { result.current.handleToggleAdditionalLineToCut('LINE_Y'); }); expect(result.current.additionalLinesToCut.size).toBe(2); act(() => { result.current.handleToggleAdditionalLineToCut('LINE_X'); }); expect(result.current.additionalLinesToCut.has('LINE_X')).toBe(false); expect(result.current.additionalLinesToCut.has('LINE_Y')).toBe(true); }); it('ignores empty / falsy line ids', () => { const { result } = renderHook(() => useAnalysis()); act(() => { result.current.handleToggleAdditionalLineToCut(''); }); expect(result.current.additionalLinesToCut.size).toBe(0); }); }); it('forwards a non-empty additional_lines_to_cut to step2', async () => { const detected = ['LINE_A']; mockRunAnalysisStep1.mockResolvedValue({ can_proceed: true, message: '', lines_overloaded: detected, }); const stream = makeStream( JSON.stringify({ type: 'result', actions: {}, lines_overloaded: detected, message: '', dc_fallback: false }) + '\n', ); mockRunAnalysisStep2Stream.mockResolvedValue({ ok: true, body: stream }); const { result } = renderHook(() => useAnalysis()); act(() => { result.current.handleToggleAdditionalLineToCut('EXTRA_1'); result.current.handleToggleAdditionalLineToCut('EXTRA_2'); }); await act(async () => { await result.current.handleRunAnalysis(['LINE_X'], vi.fn(), vi.fn()); }); expect(mockRunAnalysisStep2Stream).toHaveBeenCalledWith( expect.objectContaining({ additional_lines_to_cut: expect.arrayContaining(['EXTRA_1', 'EXTRA_2']), }), expect.any(AbortSignal), ); // Sanity: the extras pass-through preserves the operator's exact // selection — no reordering / dedup at the hook layer. const call = mockRunAnalysisStep2Stream.mock.calls[0][0]; expect(call.additional_lines_to_cut).toHaveLength(2); }); it('processes PDF and result NDJSON events from stream', async () => { const detected = ['LINE_A']; mockRunAnalysisStep1.mockResolvedValue({ can_proceed: true, message: '', lines_overloaded: detected, }); const pdfEvent = JSON.stringify({ type: 'pdf', pdf_url: '/results/pdf/graph.pdf', pdf_path: '/tmp/graph.pdf' }); const resultEvent = JSON.stringify({ type: 'result', actions: { act_1: { description_unitaire: 'Test action', rho_before: [0.95], rho_after: [0.80], max_rho: 0.80, max_rho_line: 'LINE_A', is_rho_reduction: true, }, }, lines_overloaded: detected, message: 'Analysis done', dc_fallback: false, }); const stream = makeStream(`${pdfEvent}\n${resultEvent}\n`); mockRunAnalysisStep2Stream.mockResolvedValue({ ok: true, body: stream }); const { result } = renderHook(() => useAnalysis()); const setSuggested = vi.fn(); await act(async () => { await result.current.handleRunAnalysis(['LINE_X'], vi.fn(), setSuggested); }); // PDF event sets result.pdf_url expect(result.current.result?.pdf_url).toBe('/results/pdf/graph.pdf'); // Result event sets pending result and suggested IDs expect(result.current.pendingAnalysisResult).not.toBeNull(); expect(result.current.pendingAnalysisResult?.actions?.act_1).toBeDefined(); expect(setSuggested).toHaveBeenCalled(); }); it("triggers setActiveTab('overflow') immediately when pdf event is received", async () => { const detected = ['LINE_A']; mockRunAnalysisStep1.mockResolvedValue({ can_proceed: true, message: '', lines_overloaded: detected, }); const pdfEvent = JSON.stringify({ type: 'pdf', pdf_url: '/results/pdf/graph.pdf', pdf_path: '/tmp/graph.pdf' }); const resultEvent = JSON.stringify({ type: 'result', actions: {}, lines_overloaded: detected, message: 'Analysis done', dc_fallback: false, }); const stream = makeStream(`${pdfEvent}\n${resultEvent}\n`); mockRunAnalysisStep2Stream.mockResolvedValue({ ok: true, body: stream }); const { result } = renderHook(() => useAnalysis()); const setSuggested = vi.fn(); const setActiveTab = vi.fn(); await act(async () => { await result.current.handleRunAnalysis(['LINE_X'], vi.fn(), setSuggested, setActiveTab); }); // Verify setActiveTab was called with 'overflow' expect(setActiveTab).toHaveBeenCalledWith('overflow'); // Ensure it was called before or same time as result update expect(result.current.result?.pdf_url).toBe('/results/pdf/graph.pdf'); }); it('sets error on stream error event', async () => { const detected = ['LINE_A']; mockRunAnalysisStep1.mockResolvedValue({ can_proceed: true, message: '', lines_overloaded: detected, }); const stream = makeStream( JSON.stringify({ type: 'error', message: 'Backend crashed' }) + '\n', ); mockRunAnalysisStep2Stream.mockResolvedValue({ ok: true, body: stream }); const { result } = renderHook(() => useAnalysis()); await act(async () => { await result.current.handleRunAnalysis(['LINE_X'], vi.fn(), vi.fn()); }); expect(toastMessages('error')).toContain('Analysis failed: Backend crashed'); }); it('sets error when step2 rejects', async () => { mockRunAnalysisStep1.mockResolvedValue({ can_proceed: true, message: '', lines_overloaded: ['LINE_A'], }); mockRunAnalysisStep2Stream.mockRejectedValue(new Error('Network error')); const { result } = renderHook(() => useAnalysis()); await act(async () => { await result.current.handleRunAnalysis(['LINE_X'], vi.fn(), vi.fn()); }); expect(toastMessages('error')).toContain('Network error'); expect(result.current.analysisLoading).toBe(false); }); describe('cancellation (D5)', () => { it('passes an AbortSignal to step 1', async () => { mockRunAnalysisStep1.mockResolvedValue({ can_proceed: false, message: 'stop', lines_overloaded: [] }); const { result } = renderHook(() => useAnalysis()); await act(async () => { await result.current.handleRunAnalysis(['LINE_X'], vi.fn(), vi.fn()); }); expect(mockRunAnalysisStep1).toHaveBeenCalledWith(['LINE_X'], expect.any(AbortSignal)); }); it('cancelAnalysis aborts the run and surfaces a cancellation notice, not an error', async () => { // Step 1 hangs until its signal aborts, then rejects like a real // aborted request. mockRunAnalysisStep1.mockImplementation((_els: string[], signal?: AbortSignal) => new Promise((_resolve, reject) => { signal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError'))); }), ); const { result } = renderHook(() => useAnalysis()); let runPromise!: Promise; await act(async () => { runPromise = result.current.handleRunAnalysis(['LINE_X'], vi.fn(), vi.fn()); await Promise.resolve(); }); expect(result.current.analysisLoading).toBe(true); await act(async () => { result.current.cancelAnalysis(); await runPromise; }); expect(result.current.analysisLoading).toBe(false); expect(toastMessages('info')).toContain('Analysis cancelled.'); expect(toastMessages('error')).toEqual([]); expect(mockRunAnalysisStep2Stream).not.toHaveBeenCalled(); }); it('cancelAnalysis is a no-op when nothing is running', () => { const { result } = renderHook(() => useAnalysis()); expect(() => result.current.cancelAnalysis()).not.toThrow(); }); }); describe('handleToggleOverload', () => { it('adds and removes overloads from selection', () => { const { result } = renderHook(() => useAnalysis()); act(() => { result.current.handleToggleOverload('LINE_A'); }); expect(result.current.selectedOverloads.has('LINE_A')).toBe(true); act(() => { result.current.handleToggleOverload('LINE_A'); }); expect(result.current.selectedOverloads.has('LINE_A')).toBe(false); }); }); describe('handleDisplayPrioritizedActions', () => { it('merges pending result into result and clears pending', async () => { const { result } = renderHook(() => useAnalysis()); // Simulate having a pending result const pendingResult = { actions: { act_1: { description_unitaire: 'Reco', rho_before: [1.1], rho_after: [0.9], max_rho: 0.9, max_rho_line: 'LINE_A', is_rho_reduction: true, }, }, lines_overloaded: ['LINE_A'], message: 'OK', dc_fallback: false, pdf_path: null, pdf_url: null, }; act(() => { result.current.setPendingAnalysisResult(pendingResult); }); act(() => { result.current.handleDisplayPrioritizedActions(new Set()); }); expect(result.current.result?.actions?.act_1).toBeDefined(); expect(result.current.pendingAnalysisResult).toBeNull(); }); it('calls setActiveTab("action") when provided', () => { const { result } = renderHook(() => useAnalysis()); const pendingResult = { actions: { act_1: { description_unitaire: 'Reco', rho_before: [1.1], rho_after: [0.9], max_rho: 0.9, max_rho_line: 'LINE_A', is_rho_reduction: true, }, }, lines_overloaded: ['LINE_A'], message: 'OK', dc_fallback: false, pdf_path: null, pdf_url: null, }; act(() => { result.current.setPendingAnalysisResult(pendingResult); }); const setActiveTab = vi.fn(); act(() => { result.current.handleDisplayPrioritizedActions(new Set(), setActiveTab); }); expect(setActiveTab).toHaveBeenCalledWith('action'); }); it('preserves manually selected actions during merge', async () => { const { result } = renderHook(() => useAnalysis()); // Set an existing result with a manual action act(() => { result.current.setResult({ actions: { manual_1: { description_unitaire: 'Manual', rho_before: null, rho_after: [0.85], max_rho: 0.85, max_rho_line: 'LINE_B', is_rho_reduction: true, is_manual: true, }, }, lines_overloaded: ['LINE_A'], message: '', dc_fallback: false, pdf_path: null, pdf_url: null, }); }); // Set pending result with new recommended actions act(() => { result.current.setPendingAnalysisResult({ actions: { reco_1: { description_unitaire: 'Reco', rho_before: [1.1], rho_after: [0.9], max_rho: 0.9, max_rho_line: 'LINE_A', is_rho_reduction: true, }, }, lines_overloaded: ['LINE_A'], message: 'OK', dc_fallback: false, pdf_path: null, pdf_url: null, }); }); // Display prioritized, keeping manual_1 selected act(() => { result.current.handleDisplayPrioritizedActions(new Set(['manual_1'])); }); // Both actions should be in the final result expect(result.current.result?.actions?.manual_1).toBeDefined(); expect(result.current.result?.actions?.reco_1).toBeDefined(); }); it('preserves a manually added action that is ALSO returned by the recommender (overlap case)', async () => { // Regression: a user "first guess" action that happens // to coincide with a suggestion from the recommender // used to disappear from the Selected bucket after the // analysis was displayed, because handleDisplayPrioritized // used the plain analysis entry (without is_manual) for // the overlapping id. The fix preserves the // manually-added entry (keeping is_manual=true) so it // stays in Selected and the "was also suggested" warning // can fire. const { result } = renderHook(() => useAnalysis()); act(() => { result.current.setResult({ actions: { overlap_1: { description_unitaire: 'Manual guess', rho_before: null, rho_after: [0.7], max_rho: 0.7, max_rho_line: 'LINE_A', is_rho_reduction: true, is_manual: true, }, }, lines_overloaded: ['LINE_A'], message: '', dc_fallback: false, pdf_path: null, pdf_url: null, }); }); act(() => { result.current.setPendingAnalysisResult({ actions: { overlap_1: { description_unitaire: 'Recommender entry', rho_before: [1.1], rho_after: [0.75], max_rho: 0.75, max_rho_line: 'LINE_A', is_rho_reduction: true, }, reco_other: { description_unitaire: 'Another suggestion', rho_before: [1.2], rho_after: [0.85], max_rho: 0.85, max_rho_line: 'LINE_A', is_rho_reduction: true, }, }, lines_overloaded: ['LINE_A'], message: 'OK', dc_fallback: false, pdf_path: null, pdf_url: null, }); }); // Display prioritized — even with an EMPTY selectedActionIds // (mirrors resetForAnalysisRun clearing the selection of // non-manual favorites) the manual action must survive. act(() => { result.current.handleDisplayPrioritizedActions(new Set()); }); // Overlapping id must remain and stay flagged as manual. expect(result.current.result?.actions?.overlap_1).toBeDefined(); expect(result.current.result?.actions?.overlap_1?.is_manual).toBe(true); // The new purely-suggested action is merged in as usual. expect(result.current.result?.actions?.reco_other).toBeDefined(); expect(result.current.result?.actions?.reco_other?.is_manual).toBeFalsy(); }); }); describe('overload filtering in step 2', () => { it('uses previously selected overloads when still relevant', async () => { const detected = ['LINE_A', 'LINE_B', 'LINE_C']; mockRunAnalysisStep1.mockResolvedValue({ can_proceed: true, message: '', lines_overloaded: detected, }); const stream = makeStream( JSON.stringify({ type: 'result', actions: {}, lines_overloaded: detected, message: '', dc_fallback: false }) + '\n', ); mockRunAnalysisStep2Stream.mockResolvedValue({ ok: true, body: stream }); const { result } = renderHook(() => useAnalysis()); // Pre-select a subset of overloads act(() => { result.current.setSelectedOverloads(new Set(['LINE_A', 'LINE_B'])); }); await act(async () => { await result.current.handleRunAnalysis(['LINE_X'], vi.fn(), vi.fn()); }); // Should resolve only the intersection of detected and previously selected expect(mockRunAnalysisStep2Stream).toHaveBeenCalledWith( expect.objectContaining({ selected_overloads: ['LINE_A', 'LINE_B'], all_overloads: detected, }), expect.any(AbortSignal), ); }); it('resets to all detected when previous selection has no overlap', async () => { const detected = ['LINE_C', 'LINE_D']; mockRunAnalysisStep1.mockResolvedValue({ can_proceed: true, message: '', lines_overloaded: detected, }); const stream = makeStream( JSON.stringify({ type: 'result', actions: {}, lines_overloaded: detected, message: '', dc_fallback: false }) + '\n', ); mockRunAnalysisStep2Stream.mockResolvedValue({ ok: true, body: stream }); const { result } = renderHook(() => useAnalysis()); // Pre-select overloads that won't be detected act(() => { result.current.setSelectedOverloads(new Set(['LINE_A', 'LINE_B'])); }); await act(async () => { await result.current.handleRunAnalysis(['LINE_X'], vi.fn(), vi.fn()); }); // Should fall back to all detected expect(mockRunAnalysisStep2Stream).toHaveBeenCalledWith( expect.objectContaining({ selected_overloads: detected, all_overloads: detected, }), expect.any(AbortSignal), ); }); }); describe('info message auto-clear', () => { it('raises an info toast that the store auto-dismisses after its timeout', () => { vi.useFakeTimers(); const { result } = renderHook(() => useAnalysis()); act(() => { result.current.setInfoMessage('Temporary message'); }); expect(toastMessages('info')).toContain('Temporary message'); act(() => { vi.advanceTimersByTime(DEFAULT_TIMEOUT_MS); }); expect(toastMessages('info')).not.toContain('Temporary message'); vi.useRealTimers(); }); }); describe('interaction logging', () => { it('logs analysis_step1_started and analysis_step1_completed on successful step1', async () => { mockRunAnalysisStep1.mockResolvedValue({ can_proceed: true, message: 'No overloads detected', lines_overloaded: [], }); const { result } = renderHook(() => useAnalysis()); await act(async () => { await result.current.handleRunAnalysis(['LINE_X'], vi.fn(), vi.fn()); }); const log = interactionLogger.getLog(); expect(log.length).toBeGreaterThanOrEqual(2); expect(log[0].type).toBe('analysis_step1_started'); expect(log[0].details).toEqual({ element: 'LINE_X' }); expect(log[1].type).toBe('analysis_step1_completed'); expect(log[1].details).toEqual({ can_proceed: true, overloads_detected: 0 }); // Both should share same correlation_id expect(log[1].correlation_id).toBe(log[0].correlation_id); expect(log[1].duration_ms).toBeGreaterThanOrEqual(0); }); it('logs step1_started but not step1_completed when can_proceed=false', async () => { mockRunAnalysisStep1.mockResolvedValue({ can_proceed: false, message: 'Network not loaded', lines_overloaded: [], }); const { result } = renderHook(() => useAnalysis()); await act(async () => { await result.current.handleRunAnalysis(['LINE_X'], vi.fn(), vi.fn()); }); const log = interactionLogger.getLog(); expect(log).toHaveLength(1); expect(log[0].type).toBe('analysis_step1_started'); }); it('logs full step1+step2 cycle with correlation IDs', async () => { const detected = ['LINE_A']; mockRunAnalysisStep1.mockResolvedValue({ can_proceed: true, message: '', lines_overloaded: detected, }); const resultEvent = JSON.stringify({ type: 'result', actions: { act_1: { description_unitaire: 'A', rho_before: [1.1], rho_after: [0.9], max_rho: 0.9, max_rho_line: 'LINE_A', is_rho_reduction: true } }, lines_overloaded: detected, message: 'Done', dc_fallback: false, }); const stream = makeStream(`${resultEvent}\n`); mockRunAnalysisStep2Stream.mockResolvedValue({ ok: true, body: stream }); const { result } = renderHook(() => useAnalysis()); await act(async () => { await result.current.handleRunAnalysis(['LINE_X'], vi.fn(), vi.fn()); }); const log = interactionLogger.getLog(); const types = log.map(e => e.type); expect(types).toContain('analysis_step1_started'); expect(types).toContain('analysis_step1_completed'); expect(types).toContain('analysis_step2_started'); expect(types).toContain('analysis_step2_completed'); // step1 pair shares correlation const s1Start = log.find(e => e.type === 'analysis_step1_started')!; const s1End = log.find(e => e.type === 'analysis_step1_completed')!; expect(s1End.correlation_id).toBe(s1Start.correlation_id); // step2 pair shares a different correlation const s2Start = log.find(e => e.type === 'analysis_step2_started')!; const s2End = log.find(e => e.type === 'analysis_step2_completed')!; expect(s2End.correlation_id).toBe(s2Start.correlation_id); expect(s2Start.correlation_id).not.toBe(s1Start.correlation_id); // step2 completion carries the action count under the // spec-conformant `n_actions` key (was `actions_count`). expect(s2End.details.n_actions).toBe(1); }); it('logs overload_toggled with overload + selected (spec-conformant)', () => { // Replay contract (docs/features/interaction-logging.md): // { overload: string, selected: boolean } // `selected` must reflect the state AFTER the toggle so a // replay agent knows whether this click was a check or uncheck. const { result } = renderHook(() => useAnalysis()); // First toggle — overload moves from unselected → selected. act(() => { result.current.handleToggleOverload('LINE_B'); }); let log = interactionLogger.getLog(); expect(log).toHaveLength(1); expect(log[0].type).toBe('overload_toggled'); expect(log[0].details).toEqual({ overload: 'LINE_B', selected: true }); // Second toggle on the same overload — now selected → unselected. act(() => { result.current.handleToggleOverload('LINE_B'); }); log = interactionLogger.getLog(); expect(log).toHaveLength(2); expect(log[1].details).toEqual({ overload: 'LINE_B', selected: false }); }); it('logs prioritized_actions_displayed with n_actions (spec-conformant)', () => { // Replay contract (docs/features/interaction-logging.md): // { n_actions: number } // Previously emitted `{ actions_count }` — the key name drifted // from the spec and any replay agent reading // `details.n_actions` would see `undefined`. const { result } = renderHook(() => useAnalysis()); act(() => { result.current.setPendingAnalysisResult({ actions: { a1: { description_unitaire: 'X', rho_before: [1.0], rho_after: [0.9], max_rho: 0.9, max_rho_line: 'L', is_rho_reduction: true }, a2: { description_unitaire: 'Y', rho_before: [1.0], rho_after: [0.85], max_rho: 0.85, max_rho_line: 'L', is_rho_reduction: true }, }, lines_overloaded: ['L'], message: 'OK', dc_fallback: false, pdf_path: null, pdf_url: null, }); }); act(() => { result.current.handleDisplayPrioritizedActions(new Set()); }); const log = interactionLogger.getLog(); expect(log).toHaveLength(1); expect(log[0].type).toBe('prioritized_actions_displayed'); expect(log[0].details).toEqual({ n_actions: 2 }); }); it('does not log when handleRunAnalysis is called with empty branch', async () => { const { result } = renderHook(() => useAnalysis()); await act(async () => { await result.current.handleRunAnalysis([], vi.fn(), vi.fn()); }); expect(interactionLogger.getLog()).toHaveLength(0); }); }); describe('action origin from recommender model', () => { const actionPayload = { description_unitaire: 'A', rho_before: [1.1], rho_after: [0.9], max_rho: 0.9, max_rho_line: 'LINE_A', is_rho_reduction: true, }; it('stamps origin from active_model on step-2 recommender actions', async () => { mockRunAnalysisStep1.mockResolvedValue({ can_proceed: true, message: '', lines_overloaded: ['LINE_A'] }); const resultEvent = JSON.stringify({ type: 'result', actions: { act_1: actionPayload }, active_model: 'random_overflow', lines_overloaded: ['LINE_A'], message: 'Done', dc_fallback: false, }); mockRunAnalysisStep2Stream.mockResolvedValue({ ok: true, body: makeStream(`${resultEvent}\n`) }); const { result } = renderHook(() => useAnalysis()); await act(async () => { await result.current.handleRunAnalysis(['LINE_X'], vi.fn(), vi.fn()); }); expect(result.current.pendingAnalysisResult?.actions?.act_1?.origin).toBe('random_overflow'); }); it('defaults origin to "expert" when the result event omits active_model', async () => { mockRunAnalysisStep1.mockResolvedValue({ can_proceed: true, message: '', lines_overloaded: ['LINE_A'] }); const resultEvent = JSON.stringify({ type: 'result', actions: { act_1: actionPayload }, lines_overloaded: ['LINE_A'], message: 'Done', dc_fallback: false, }); mockRunAnalysisStep2Stream.mockResolvedValue({ ok: true, body: makeStream(`${resultEvent}\n`) }); const { result } = renderHook(() => useAnalysis()); await act(async () => { await result.current.handleRunAnalysis(['LINE_X'], vi.fn(), vi.fn()); }); expect(result.current.pendingAnalysisResult?.actions?.act_1?.origin).toBe('expert'); }); it('preserves an existing "user" origin when the recommender re-emits the same action', async () => { mockRunAnalysisStep1.mockResolvedValue({ can_proceed: true, message: '', lines_overloaded: ['LINE_A'] }); const resultEvent = JSON.stringify({ type: 'result', actions: { firstguess: actionPayload }, active_model: 'expert', lines_overloaded: ['LINE_A'], message: 'Done', dc_fallback: false, }); mockRunAnalysisStep2Stream.mockResolvedValue({ ok: true, body: makeStream(`${resultEvent}\n`) }); const { result } = renderHook(() => useAnalysis()); // Operator made a "first guess" before analysis — origin "user". act(() => { result.current.setResult({ pdf_path: null, pdf_url: null, actions: { firstguess: { ...actionPayload, rho_before: null, is_manual: true, origin: 'user' }, }, lines_overloaded: ['LINE_A'], message: '', dc_fallback: false, }); }); await act(async () => { await result.current.handleRunAnalysis(['LINE_X'], vi.fn(), vi.fn()); }); // The recommender re-emitted "firstguess" but the operator's // provenance wins — it was their idea first. expect(result.current.pendingAnalysisResult?.actions?.firstguess?.origin).toBe('user'); }); it('handleDisplayPrioritizedActions preserves origin through the merge', () => { const { result } = renderHook(() => useAnalysis()); act(() => { result.current.setPendingAnalysisResult({ pdf_path: null, pdf_url: null, actions: { reco_1: { ...actionPayload, origin: 'random_overflow' }, }, lines_overloaded: ['LINE_A'], message: 'OK', dc_fallback: false, }); }); act(() => { result.current.handleDisplayPrioritizedActions(new Set()); }); expect(result.current.result?.actions?.reco_1?.origin).toBe('random_overflow'); }); }); describe('interaction logging — step2 payload', () => { it('step2_started carries the full spec payload (element + selected_overloads + all_overloads + monitor_deselected)', async () => { // Replay contract (docs/features/interaction-logging.md): // { element, selected_overloads, all_overloads, monitor_deselected } // Previously the hook emitted only the latter two, so a // replay agent had no way to know which contingency or // which overload set was in scope. const detected = ['LINE_A', 'LINE_B']; mockRunAnalysisStep1.mockResolvedValue({ can_proceed: true, message: '', lines_overloaded: detected, }); const stream = makeStream( JSON.stringify({ type: 'result', actions: {}, lines_overloaded: detected, message: '', dc_fallback: false }) + '\n', ); mockRunAnalysisStep2Stream.mockResolvedValue({ ok: true, body: stream }); const { result } = renderHook(() => useAnalysis()); await act(async () => { await result.current.handleRunAnalysis(['LINE_X'], vi.fn(), vi.fn()); }); const s2Start = interactionLogger.getLog().find(e => e.type === 'analysis_step2_started')!; expect(s2Start.details.element).toBe('LINE_X'); expect(s2Start.details.selected_overloads).toEqual(detected); expect(s2Start.details.all_overloads).toEqual(detected); expect(s2Start.details.monitor_deselected).toBe(false); }); }); });