Spaces:
Sleeping
Sleeping
| // Copyright (c) 2025-2026, RTE (https://www.rte-france.com) | |
| // This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. | |
| // If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, | |
| // you can obtain one at http://mozilla.org/MPL/2.0/. | |
| // SPDX-License-Identifier: MPL-2.0 | |
| // This file is part of Co-Study4Grid a Power Grid Study tool Assistant Interface to help solve contigencies for a grid state under study. | |
| import { describe, it, expect } from 'vitest'; | |
| import { buildSessionResult } from './sessionUtils'; | |
| import type { SessionInput } from './sessionUtils'; | |
| import type { AnalysisResult, ActionDetail, CombinedAction } from '../types'; | |
| // ===== Helpers ===== | |
| const makeAction = (desc: string, overrides: Partial<ActionDetail> = {}): ActionDetail => ({ | |
| description_unitaire: desc, | |
| rho_before: [1.05, 1.1], | |
| rho_after: [0.9, 0.95], | |
| max_rho: 0.95, | |
| max_rho_line: 'LINE_X', | |
| is_rho_reduction: true, | |
| ...overrides, | |
| }); | |
| const makeResult = (overrides: Partial<AnalysisResult> = {}): AnalysisResult => ({ | |
| pdf_path: '/tmp/overflow.pdf', | |
| pdf_url: '/results/pdf/overflow.pdf', | |
| actions: {}, | |
| lines_overloaded: ['LINE_B'], | |
| message: 'Analysis done', | |
| dc_fallback: false, | |
| ...overrides, | |
| }); | |
| const baseInput: SessionInput = { | |
| networkPath: '/data/network', | |
| actionPath: '/data/actions.json', | |
| layoutPath: '', | |
| minLineReconnections: 2.0, | |
| minCloseCoupling: 3.0, | |
| minOpenCoupling: 2.0, | |
| minLineDisconnections: 3.0, | |
| minPst: 1.0, | |
| minLoadShedding: 0.0, | |
| minRenewableCurtailmentActions: 0.0, | |
| minRedispatch: 0.0, | |
| allowedActionTypes: [], | |
| nPrioritizedActions: 10, | |
| linesMonitoringPath: '/data/monitoring.csv', | |
| monitoringFactor: 0.95, | |
| preExistingOverloadThreshold: 0.02, | |
| ignoreReconnections: false, | |
| pypowsyblFastMode: true, | |
| selectedBranch: 'LINE_A', | |
| selectedOverloads: new Set(['LINE_B']), | |
| monitorDeselected: false, | |
| nOverloads: [], | |
| n1Overloads: ['LINE_B'], | |
| result: null, | |
| selectedActionIds: new Set(), | |
| rejectedActionIds: new Set(), | |
| manuallyAddedIds: new Set(), | |
| suggestedByRecommenderIds: new Set(), | |
| interactionLog: [], | |
| }; | |
| // ===== Tests ===== | |
| describe('buildSessionResult — structure', () => { | |
| it('includes a saved_at ISO timestamp', () => { | |
| const out = buildSessionResult(baseInput); | |
| expect(out.saved_at).toMatch(/^\d{4}-\d{2}-\d{2}T/); | |
| }); | |
| it('serialises all configuration fields', () => { | |
| const out = buildSessionResult(baseInput); | |
| expect(out.configuration).toEqual({ | |
| network_path: '/data/network', | |
| action_file_path: '/data/actions.json', | |
| layout_path: '', | |
| min_line_reconnections: 2.0, | |
| min_close_coupling: 3.0, | |
| min_open_coupling: 2.0, | |
| min_line_disconnections: 3.0, | |
| min_pst: 1.0, | |
| min_load_shedding: 0.0, | |
| min_renewable_curtailment_actions: 0.0, | |
| min_redispatch: 0.0, | |
| allowed_action_types: [], | |
| n_prioritized_actions: 10, | |
| lines_monitoring_path: '/data/monitoring.csv', | |
| monitoring_factor: 0.95, | |
| pre_existing_overload_threshold: 0.02, | |
| ignore_reconnections: false, | |
| pypowsybl_fast_mode: true, | |
| }); | |
| }); | |
| it('serialises contingency with selected_overloads array', () => { | |
| const out = buildSessionResult({ | |
| ...baseInput, | |
| selectedBranch: 'LINE_A', | |
| selectedOverloads: new Set(['LINE_B', 'LINE_C']), | |
| monitorDeselected: true, | |
| }); | |
| expect(out.contingency.disconnected_element).toBe('LINE_A'); | |
| expect(out.contingency.selected_overloads).toEqual(expect.arrayContaining(['LINE_B', 'LINE_C'])); | |
| expect(out.contingency.selected_overloads).toHaveLength(2); | |
| expect(out.contingency.monitor_deselected).toBe(true); | |
| }); | |
| it('serialises overload lists from diagrams and result', () => { | |
| const out = buildSessionResult({ | |
| ...baseInput, | |
| nOverloads: ['LINE_PRE'], | |
| n1Overloads: ['LINE_B', 'LINE_C'], | |
| result: makeResult({ lines_overloaded: ['LINE_B'] }), | |
| }); | |
| expect(out.overloads.n_overloads).toEqual(['LINE_PRE']); | |
| expect(out.overloads.n1_overloads).toEqual(['LINE_B', 'LINE_C']); | |
| expect(out.overloads.resolved_overloads).toEqual(['LINE_B']); | |
| }); | |
| it('sets resolved_overloads to empty when result is null', () => { | |
| const out = buildSessionResult({ ...baseInput, result: null }); | |
| expect(out.overloads.resolved_overloads).toEqual([]); | |
| }); | |
| it('persists lines_overloaded_rho when length matches the element list', () => { | |
| // PR #88 added per-element loading ratios that feed the sticky | |
| // sidebar header. They must survive save/reload so the sticky | |
| // percentages render after a session restore without requiring | |
| // a fresh analysis run. | |
| const out = buildSessionResult({ | |
| ...baseInput, | |
| nOverloads: ['LINE_PRE'], | |
| nOverloadsRho: [1.04], | |
| n1Overloads: ['LINE_B', 'LINE_C'], | |
| n1OverloadsRho: [1.23, 1.07], | |
| }); | |
| expect(out.overloads.n_overloads_rho).toEqual([1.04]); | |
| expect(out.overloads.n1_overloads_rho).toEqual([1.23, 1.07]); | |
| }); | |
| it('omits rho arrays when the length does not match the element list', () => { | |
| // Guard against older payloads / partial backends: if the rho | |
| // array is shorter than the name array, prefer omitting the | |
| // field entirely over writing misaligned percentages. | |
| const out = buildSessionResult({ | |
| ...baseInput, | |
| n1Overloads: ['LINE_B', 'LINE_C'], | |
| n1OverloadsRho: [1.23], | |
| }); | |
| expect(out.overloads.n1_overloads_rho).toBeUndefined(); | |
| }); | |
| it('omits rho arrays when not provided at all (legacy session flow)', () => { | |
| const out = buildSessionResult({ | |
| ...baseInput, | |
| n1Overloads: ['LINE_B'], | |
| }); | |
| expect(out.overloads.n_overloads_rho).toBeUndefined(); | |
| expect(out.overloads.n1_overloads_rho).toBeUndefined(); | |
| }); | |
| it('populates overflow_graph when result has pdf_url', () => { | |
| const out = buildSessionResult({ | |
| ...baseInput, | |
| result: makeResult({ pdf_url: '/results/pdf/x.pdf', pdf_path: '/tmp/x.pdf' }), | |
| }); | |
| expect(out.overflow_graph).toEqual({ pdf_url: '/results/pdf/x.pdf', pdf_path: '/tmp/x.pdf' }); | |
| }); | |
| it('sets overflow_graph to null when result has no pdf_url', () => { | |
| const out = buildSessionResult({ | |
| ...baseInput, | |
| result: makeResult({ pdf_url: null, pdf_path: null }), | |
| }); | |
| expect(out.overflow_graph).toBeNull(); | |
| }); | |
| it('sets analysis to null when result is null', () => { | |
| const out = buildSessionResult({ ...baseInput, result: null }); | |
| expect(out.analysis).toBeNull(); | |
| }); | |
| it('preserves action detail fields in analysis', () => { | |
| const action = makeAction('Switch bus 1', { | |
| rho_before: [1.1], rho_after: [0.88], | |
| max_rho: 0.88, max_rho_line: 'LINE_B', | |
| is_rho_reduction: true, | |
| non_convergence: null, | |
| action_topology: { lines_ex_bus: { LINE_X: 1 }, lines_or_bus: {}, gens_bus: {}, loads_bus: {} }, | |
| }); | |
| const out = buildSessionResult({ | |
| ...baseInput, | |
| result: makeResult({ actions: { act1: action } }), | |
| }); | |
| const saved = out.analysis!.actions.act1; | |
| expect(saved.description_unitaire).toBe('Switch bus 1'); | |
| expect(saved.rho_before).toEqual([1.1]); | |
| expect(saved.rho_after).toEqual([0.88]); | |
| expect(saved.max_rho).toBe(0.88); | |
| expect(saved.max_rho_line).toBe('LINE_B'); | |
| expect(saved.is_rho_reduction).toBe(true); | |
| expect(saved.non_convergence).toBeNull(); | |
| expect(saved.action_topology).toEqual({ lines_ex_bus: { LINE_X: 1 }, lines_or_bus: {}, gens_bus: {}, loads_bus: {} }); | |
| }); | |
| it('persists redispatch_details so it survives save → reload (regression)', () => { | |
| // Regression: redispatch_details was declared on SavedActionEntry | |
| // AND assigned in useSession.handleRestoreSession, but | |
| // buildSessionResult silently dropped it on save (load_shedding / | |
| // curtailment / pst details were copied, redispatch was not), so | |
| // reloaded sessions lost the redispatch editor headroom. The save | |
| // path must copy it like the other enrichment families. | |
| const redispatch_details = [{ | |
| gen_name: 'GEN_A', voltage_level_id: 'VL_A', | |
| delta_mw: 12.5, target_mw: 112.5, direction: 'up' as const, | |
| current_mw: 100.0, max_raise_mw: 40.0, max_lower_mw: 60.0, | |
| }]; | |
| const action = makeAction('Redispatch GEN_A', { redispatch_details }); | |
| const out = buildSessionResult({ | |
| ...baseInput, | |
| result: makeResult({ actions: { redispatch_GEN_A: action } }), | |
| }); | |
| expect(out.analysis!.actions.redispatch_GEN_A.redispatch_details).toEqual(redispatch_details); | |
| }); | |
| it('serializes recommender restriction config (allowed_action_types + min_redispatch)', () => { | |
| const out = buildSessionResult({ | |
| ...baseInput, | |
| minRedispatch: 2.0, | |
| allowedActionTypes: ['redispatch', 'disco'], | |
| }); | |
| expect(out.configuration.min_redispatch).toBe(2.0); | |
| expect(out.configuration.allowed_action_types).toEqual(['redispatch', 'disco']); | |
| }); | |
| it('persists lines_overloaded_after so it survives save → reload (regression)', () => { | |
| // Reload-fidelity contract (docs/features/interaction-logging.md § | |
| // Session reload fidelity): the post-action overload list is | |
| // read back in useSession.handleRestoreSession to feed the | |
| // Remedial-Action NAD/SLD overload halos (PR #83). Before | |
| // this regression test landed, the field was declared on | |
| // SavedActionEntry AND the restore path assigned it, but | |
| // buildSessionResult silently dropped it on save — so on | |
| // reload the halos disappeared until a fresh analysis run. | |
| // Layer-2 conformity check (scripts/check_session_fidelity.py) | |
| // flags this as a "restore_only" asymmetry. | |
| const action = makeAction('Disconnect LINE_C', { | |
| lines_overloaded_after: ['LINE_D', 'LINE_E'], | |
| }); | |
| const out = buildSessionResult({ | |
| ...baseInput, | |
| result: makeResult({ actions: { disco_LINE_C: action } }), | |
| }); | |
| expect(out.analysis!.actions.disco_LINE_C.lines_overloaded_after) | |
| .toEqual(['LINE_D', 'LINE_E']); | |
| }); | |
| it('persists lines_we_care_about so the monitored-line set survives save → reload (regression)', () => { | |
| // Session-fidelity contract: without this the backend's | |
| // default monitored-line policy silently replaces the | |
| // per-study set captured during the original analysis run | |
| // on any simulate-action triggered after reload. Standalone | |
| // HTML mirror saves it at standalone_interface.html:3652. | |
| const monitored = ['LINE_D', 'LINE_E', 'LINE_F']; | |
| const out = buildSessionResult({ | |
| ...baseInput, | |
| result: makeResult({ lines_we_care_about: monitored }), | |
| }); | |
| expect(out.analysis!.lines_we_care_about).toEqual(monitored); | |
| }); | |
| it('persists computed_pairs for re-push on reload', () => { | |
| // Keyed superposition cache. Round-tripping avoids re-scoring | |
| // every combined pair from scratch after reload. | |
| const pairs = { 'actA+actB': { max_rho: 0.87 } }; | |
| const out = buildSessionResult({ | |
| ...baseInput, | |
| result: makeResult({ computed_pairs: pairs }), | |
| }); | |
| expect(out.analysis!.computed_pairs).toEqual(pairs); | |
| }); | |
| it('retains lines_overloaded_after as an empty array when all overloads are resolved', () => { | |
| // Empty array means "action solved every overload" — distinct | |
| // from undefined, which means "field unknown / not computed". | |
| // The round-trip MUST preserve the distinction so the post- | |
| // action halo logic can tell the two apart on reload. | |
| const action = makeAction('Topological rearrangement', { | |
| lines_overloaded_after: [], | |
| }); | |
| const out = buildSessionResult({ | |
| ...baseInput, | |
| result: makeResult({ actions: { topo_1: action } }), | |
| }); | |
| expect(out.analysis!.actions.topo_1.lines_overloaded_after).toEqual([]); | |
| }); | |
| it('includes action_scores in analysis', () => { | |
| const scores = { act1: { score: 0.8, rank: 1 } }; | |
| const out = buildSessionResult({ | |
| ...baseInput, | |
| result: makeResult({ actions: { act1: makeAction('X') }, action_scores: scores }), | |
| }); | |
| expect(out.analysis!.action_scores).toEqual(scores); | |
| }); | |
| it('includes dc_fallback and message in analysis', () => { | |
| const out = buildSessionResult({ | |
| ...baseInput, | |
| result: makeResult({ message: 'DC fallback used', dc_fallback: true }), | |
| }); | |
| expect(out.analysis!.message).toBe('DC fallback used'); | |
| expect(out.analysis!.dc_fallback).toBe(true); | |
| }); | |
| it('persists every per-stage execution-time value in the analysis block', () => { | |
| // The ActionFeed reminder shows a "Suggestions produced by <model> | |
| // in <X>s ⓘ" headline with a per-stage breakdown tooltip. Reloading | |
| // a session must restore the SAME numbers (no fresh analysis run | |
| // required) — all six fields end up under ``analysis.*``. | |
| const out = buildSessionResult({ | |
| ...baseInput, | |
| result: makeResult({ | |
| step1_time: 0.57, | |
| overflow_graph_time: 7.36, | |
| action_prediction_time: 0.82, | |
| assessment_time: 13.2, | |
| enrichment_time: 0.79, | |
| wall_clock_time: 24.3, | |
| }), | |
| }); | |
| expect(out.analysis).not.toBeNull(); | |
| expect(out.analysis!.step1_time).toBe(0.57); | |
| expect(out.analysis!.overflow_graph_time).toBe(7.36); | |
| expect(out.analysis!.action_prediction_time).toBe(0.82); | |
| expect(out.analysis!.assessment_time).toBe(13.2); | |
| expect(out.analysis!.enrichment_time).toBe(0.79); | |
| expect(out.analysis!.wall_clock_time).toBe(24.3); | |
| }); | |
| it('writes nulls for missing timing fields so the JSON contract stays stable', () => { | |
| // A result that came in over an older backend has only some of | |
| // the timing fields populated. We still emit the keys (as null) | |
| // so the saved-session schema doesn't change shape between | |
| // runs — easier diffing and stricter reload validation. | |
| const out = buildSessionResult({ | |
| ...baseInput, | |
| result: makeResult({ overflow_graph_time: 4.2 }), | |
| }); | |
| expect(out.analysis!.overflow_graph_time).toBe(4.2); | |
| expect(out.analysis!.action_prediction_time).toBeNull(); | |
| expect(out.analysis!.assessment_time).toBeNull(); | |
| expect(out.analysis!.step1_time).toBeNull(); | |
| expect(out.analysis!.enrichment_time).toBeNull(); | |
| expect(out.analysis!.wall_clock_time).toBeNull(); | |
| }); | |
| it('serializes a null overflow_graph_time (model that did not compute the graph)', () => { | |
| // ``overflow_graph_time`` is the only timing field allowed to | |
| // be ``null`` on a real run: the active model doesn't consume | |
| // the overflow graph (no time was spent there) — distinct from | |
| // ``0.0`` which means "cached re-run". | |
| const out = buildSessionResult({ | |
| ...baseInput, | |
| result: makeResult({ | |
| overflow_graph_time: null, | |
| action_prediction_time: 1.1, | |
| assessment_time: 3.0, | |
| }), | |
| }); | |
| expect(out.analysis!.overflow_graph_time).toBeNull(); | |
| expect(out.analysis!.action_prediction_time).toBe(1.1); | |
| expect(out.analysis!.assessment_time).toBe(3.0); | |
| }); | |
| }); | |
| describe('buildSessionResult — action origin', () => { | |
| it('serializes the action origin verbatim ("user")', () => { | |
| const out = buildSessionResult({ | |
| ...baseInput, | |
| result: makeResult({ actions: { manual_1: makeAction('M', { origin: 'user' }) } }), | |
| }); | |
| expect(out.analysis!.actions.manual_1.origin).toBe('user'); | |
| }); | |
| it('serializes a recommender model origin verbatim', () => { | |
| const out = buildSessionResult({ | |
| ...baseInput, | |
| result: makeResult({ actions: { reco_1: makeAction('R', { origin: 'random_overflow' }) } }), | |
| }); | |
| expect(out.analysis!.actions.reco_1.origin).toBe('random_overflow'); | |
| }); | |
| it('leaves origin undefined when the action has none (legacy / un-tracked)', () => { | |
| const out = buildSessionResult({ | |
| ...baseInput, | |
| result: makeResult({ actions: { act1: makeAction('A') } }), | |
| }); | |
| expect(out.analysis!.actions.act1.origin).toBeUndefined(); | |
| }); | |
| }); | |
| describe('buildSessionResult — action status tags', () => { | |
| it('is_selected is true when action is in selectedActionIds', () => { | |
| const out = buildSessionResult({ | |
| ...baseInput, | |
| result: makeResult({ actions: { act1: makeAction('A') } }), | |
| selectedActionIds: new Set(['act1']), | |
| }); | |
| expect(out.analysis!.actions.act1.status.is_selected).toBe(true); | |
| }); | |
| it('is_selected is false when action is NOT in selectedActionIds', () => { | |
| const out = buildSessionResult({ | |
| ...baseInput, | |
| result: makeResult({ actions: { act1: makeAction('A') } }), | |
| selectedActionIds: new Set(), | |
| }); | |
| expect(out.analysis!.actions.act1.status.is_selected).toBe(false); | |
| }); | |
| it('is_suggested is true when action is in suggestedByRecommenderIds', () => { | |
| const out = buildSessionResult({ | |
| ...baseInput, | |
| result: makeResult({ actions: { act1: makeAction('A') } }), | |
| suggestedByRecommenderIds: new Set(['act1']), | |
| }); | |
| expect(out.analysis!.actions.act1.status.is_suggested).toBe(true); | |
| }); | |
| it('is_suggested is false when action is NOT in suggestedByRecommenderIds', () => { | |
| const out = buildSessionResult({ | |
| ...baseInput, | |
| result: makeResult({ actions: { act1: makeAction('A') } }), | |
| suggestedByRecommenderIds: new Set(), | |
| }); | |
| expect(out.analysis!.actions.act1.status.is_suggested).toBe(false); | |
| }); | |
| it('is_rejected is true when action is in rejectedActionIds', () => { | |
| const out = buildSessionResult({ | |
| ...baseInput, | |
| result: makeResult({ actions: { act1: makeAction('A') } }), | |
| rejectedActionIds: new Set(['act1']), | |
| }); | |
| expect(out.analysis!.actions.act1.status.is_rejected).toBe(true); | |
| }); | |
| it('is_rejected is false when action is NOT in rejectedActionIds', () => { | |
| const out = buildSessionResult({ | |
| ...baseInput, | |
| result: makeResult({ actions: { act1: makeAction('A') } }), | |
| rejectedActionIds: new Set(), | |
| }); | |
| expect(out.analysis!.actions.act1.status.is_rejected).toBe(false); | |
| }); | |
| it('is_manually_simulated is true when action is in manuallyAddedIds', () => { | |
| const out = buildSessionResult({ | |
| ...baseInput, | |
| result: makeResult({ actions: { act1: makeAction('A') } }), | |
| manuallyAddedIds: new Set(['act1']), | |
| }); | |
| expect(out.analysis!.actions.act1.status.is_manually_simulated).toBe(true); | |
| }); | |
| it('is_manually_simulated is false when action is NOT in manuallyAddedIds', () => { | |
| const out = buildSessionResult({ | |
| ...baseInput, | |
| result: makeResult({ actions: { act1: makeAction('A') } }), | |
| manuallyAddedIds: new Set(), | |
| }); | |
| expect(out.analysis!.actions.act1.status.is_manually_simulated).toBe(false); | |
| }); | |
| it('all status flags are independently tracked per action', () => { | |
| const result = makeResult({ | |
| actions: { | |
| suggested: makeAction('Pure suggestion'), | |
| selected: makeAction('Starred'), | |
| rejected: makeAction('Rejected'), | |
| manual: makeAction('Manual'), | |
| }, | |
| }); | |
| const out = buildSessionResult({ | |
| ...baseInput, | |
| result, | |
| suggestedByRecommenderIds: new Set(['suggested', 'selected']), | |
| selectedActionIds: new Set(['selected']), | |
| rejectedActionIds: new Set(['rejected']), | |
| manuallyAddedIds: new Set(['manual']), | |
| }); | |
| expect(out.analysis!.actions.suggested.status).toEqual({ | |
| is_selected: false, is_suggested: true, is_rejected: false, is_manually_simulated: false, | |
| }); | |
| expect(out.analysis!.actions.selected.status).toEqual({ | |
| is_selected: true, is_suggested: true, is_rejected: false, is_manually_simulated: false, | |
| }); | |
| expect(out.analysis!.actions.rejected.status).toEqual({ | |
| is_selected: false, is_suggested: false, is_rejected: true, is_manually_simulated: false, | |
| }); | |
| expect(out.analysis!.actions.manual.status).toEqual({ | |
| is_selected: false, is_suggested: false, is_rejected: false, is_manually_simulated: true, | |
| }); | |
| }); | |
| }); | |
| describe('buildSessionResult — is_suggested edge case', () => { | |
| /** | |
| * Critical case: the user manually simulates an action BEFORE running the | |
| * recommender analysis. When the recommender later returns that same action | |
| * as a suggestion, both is_manually_simulated AND is_suggested should be true. | |
| * | |
| * Old (broken) logic: is_suggested = !manuallyAddedIds.has(id) | |
| * → would mark the action as NOT suggested because it was manually added. | |
| * | |
| * New (correct) logic: is_suggested = suggestedByRecommenderIds.has(id) | |
| * → is derived from whether the recommender ever returned the action, | |
| * independent of the manual simulation history. | |
| */ | |
| it('action manually added then also returned by recommender is both is_manually_simulated AND is_suggested', () => { | |
| const result = makeResult({ actions: { act1: makeAction('Overlap action') } }); | |
| const out = buildSessionResult({ | |
| ...baseInput, | |
| result, | |
| manuallyAddedIds: new Set(['act1']), // user ran it manually | |
| suggestedByRecommenderIds: new Set(['act1']), // recommender also returned it | |
| }); | |
| const status = out.analysis!.actions.act1.status; | |
| expect(status.is_manually_simulated).toBe(true); | |
| expect(status.is_suggested).toBe(true); | |
| }); | |
| it('action only manually added (recommender never returned it) is NOT is_suggested', () => { | |
| const result = makeResult({ actions: { act1: makeAction('Manual only') } }); | |
| const out = buildSessionResult({ | |
| ...baseInput, | |
| result, | |
| manuallyAddedIds: new Set(['act1']), | |
| suggestedByRecommenderIds: new Set(), // recommender never returned act1 | |
| }); | |
| const status = out.analysis!.actions.act1.status; | |
| expect(status.is_manually_simulated).toBe(true); | |
| expect(status.is_suggested).toBe(false); | |
| }); | |
| it('action returned by recommender then also manually re-simulated keeps is_suggested true', () => { | |
| // User selects the action via manual search after already seeing it as a suggestion | |
| const result = makeResult({ actions: { act1: makeAction('Re-simulated suggestion') } }); | |
| const out = buildSessionResult({ | |
| ...baseInput, | |
| result, | |
| manuallyAddedIds: new Set(['act1']), | |
| suggestedByRecommenderIds: new Set(['act1']), | |
| selectedActionIds: new Set(['act1']), | |
| }); | |
| const status = out.analysis!.actions.act1.status; | |
| expect(status.is_suggested).toBe(true); | |
| expect(status.is_manually_simulated).toBe(true); | |
| expect(status.is_selected).toBe(true); | |
| }); | |
| it('multiple re-analysis runs: action suggested in first run stays is_suggested after second run', () => { | |
| // Simulates suggestedByRecommenderIds accumulating across two analysis runs. | |
| // act1 was in run1, act2 was in run2; both should be is_suggested. | |
| const result = makeResult({ | |
| actions: { | |
| act1: makeAction('Run1 suggestion'), | |
| act2: makeAction('Run2 suggestion'), | |
| }, | |
| }); | |
| const out = buildSessionResult({ | |
| ...baseInput, | |
| result, | |
| suggestedByRecommenderIds: new Set(['act1', 'act2']), // accumulated across both runs | |
| }); | |
| expect(out.analysis!.actions.act1.status.is_suggested).toBe(true); | |
| expect(out.analysis!.actions.act2.status.is_suggested).toBe(true); | |
| }); | |
| }); | |
| describe('buildSessionResult — combined_actions', () => { | |
| const makeCombinedAction = (overrides: Partial<CombinedAction> = {}): CombinedAction => ({ | |
| action1_id: 'act1', | |
| action2_id: 'act2', | |
| betas: [0.5, 0.3], | |
| p_or_combined: [100, 200], | |
| max_rho: 0.85, | |
| max_rho_line: 'LINE_C', | |
| is_rho_reduction: true, | |
| description: 'Combined act1 + act2', | |
| rho_after: [0.8, 0.85], | |
| rho_before: [1.1, 1.05], | |
| estimated_max_rho: 0.82, | |
| estimated_max_rho_line: 'LINE_C', | |
| ...overrides, | |
| }); | |
| it('serialises combined_actions from analysis result', () => { | |
| const result = makeResult({ | |
| actions: { act1: makeAction('A'), act2: makeAction('B') }, | |
| combined_actions: { 'act1+act2': makeCombinedAction() }, | |
| }); | |
| const out = buildSessionResult({ ...baseInput, result }); | |
| expect(out.analysis!.combined_actions).toBeDefined(); | |
| expect(out.analysis!.combined_actions['act1+act2']).toBeDefined(); | |
| const saved = out.analysis!.combined_actions['act1+act2']; | |
| expect(saved.action1_id).toBe('act1'); | |
| expect(saved.action2_id).toBe('act2'); | |
| expect(saved.betas).toEqual([0.5, 0.3]); | |
| expect(saved.estimated_max_rho).toBe(0.82); | |
| expect(saved.is_simulated).toBe(false); | |
| }); | |
| it('marks combined_action as simulated when result.actions contains it', () => { | |
| const result = makeResult({ | |
| actions: { | |
| act1: makeAction('A'), | |
| act2: makeAction('B'), | |
| 'act1+act2': makeAction('Combined', { is_estimated: false, rho_after: [0.78, 0.80], max_rho: 0.80, max_rho_line: 'LINE_D' }), | |
| }, | |
| combined_actions: { 'act1+act2': makeCombinedAction() }, | |
| }); | |
| const out = buildSessionResult({ ...baseInput, result }); | |
| const saved = out.analysis!.combined_actions['act1+act2']; | |
| expect(saved.is_simulated).toBe(true); | |
| expect(saved.simulated_max_rho).toBe(0.80); | |
| expect(saved.simulated_max_rho_line).toBe('LINE_D'); | |
| }); | |
| it('detects simulated pair via canonical key when combined_actions key is non-canonical', () => { | |
| // Library returns combined_actions with key 'reco_X+node_merging_Y' (non-canonical) | |
| // Simulation stores result under canonical key 'node_merging_Y+reco_X' in result.actions | |
| const result = makeResult({ | |
| actions: { | |
| reco_X: makeAction('A'), | |
| node_merging_Y: makeAction('B'), | |
| // Simulation result stored under canonical (sorted) key | |
| 'node_merging_Y+reco_X': makeAction('Combined sim', { | |
| is_estimated: false, rho_after: [0.75], max_rho: 0.75, max_rho_line: 'LINE_SIM', | |
| }), | |
| }, | |
| combined_actions: { | |
| // Library key is non-canonical (r > n alphabetically) | |
| 'reco_X+node_merging_Y': makeCombinedAction({ | |
| action1_id: 'reco_X', action2_id: 'node_merging_Y', | |
| betas: [0.70, 0.81], | |
| }), | |
| }, | |
| }); | |
| const out = buildSessionResult({ ...baseInput, result }); | |
| const saved = out.analysis!.combined_actions['reco_X+node_merging_Y']; | |
| expect(saved.is_simulated).toBe(true); | |
| expect(saved.simulated_max_rho).toBe(0.75); | |
| expect(saved.simulated_max_rho_line).toBe('LINE_SIM'); | |
| // Betas should always be preserved from the estimation | |
| expect(saved.betas).toEqual([0.70, 0.81]); | |
| }); | |
| it('preserves betas even when simulation data comes from canonical key lookup', () => { | |
| const result = makeResult({ | |
| actions: { | |
| act_B: makeAction('B'), | |
| act_A: makeAction('A'), | |
| 'act_A+act_B': makeAction('Combined', { | |
| is_estimated: false, rho_after: [0.80], max_rho: 0.80, max_rho_line: 'LINE_D', | |
| }), | |
| }, | |
| combined_actions: { | |
| 'act_B+act_A': makeCombinedAction({ | |
| action1_id: 'act_B', action2_id: 'act_A', | |
| betas: [0.88, 0.91], | |
| }), | |
| }, | |
| }); | |
| const out = buildSessionResult({ ...baseInput, result }); | |
| const saved = out.analysis!.combined_actions['act_B+act_A']; | |
| expect(saved.betas).toEqual([0.88, 0.91]); | |
| expect(saved.is_simulated).toBe(true); | |
| }); | |
| it('does not mark as simulated when canonical key has estimation-only data', () => { | |
| const result = makeResult({ | |
| actions: { | |
| act_A: makeAction('A'), | |
| act_B: makeAction('B'), | |
| 'act_A+act_B': makeAction('Est only', { is_estimated: true, rho_after: null }), | |
| }, | |
| combined_actions: { | |
| 'act_B+act_A': makeCombinedAction({ action1_id: 'act_B', action2_id: 'act_A' }), | |
| }, | |
| }); | |
| const out = buildSessionResult({ ...baseInput, result }); | |
| const saved = out.analysis!.combined_actions['act_B+act_A']; | |
| expect(saved.is_simulated).toBe(false); | |
| expect(saved.simulated_max_rho).toBeNull(); | |
| }); | |
| it('combined_actions is empty object when result has no combined_actions', () => { | |
| const result = makeResult({ actions: { act1: makeAction('A') } }); | |
| const out = buildSessionResult({ ...baseInput, result }); | |
| expect(out.analysis!.combined_actions).toEqual({}); | |
| }); | |
| it('combined_actions is not present when analysis is null', () => { | |
| const out = buildSessionResult({ ...baseInput, result: null }); | |
| expect(out.analysis).toBeNull(); | |
| }); | |
| }); | |
| describe('buildSessionResult — interaction_log', () => { | |
| it('includes interaction_log when provided', () => { | |
| const log = [ | |
| { seq: 0, timestamp: '2026-03-18T10:00:00.000Z', type: 'config_loaded' as const, details: { network_path: '/data/net.xiidm' }, correlation_id: 'abc' }, | |
| { seq: 1, timestamp: '2026-03-18T10:01:00.000Z', type: 'contingency_selected' as const, details: { element: 'LINE_A' }, correlation_id: 'def' }, | |
| ]; | |
| const out = buildSessionResult({ ...baseInput, interactionLog: log }); | |
| expect(out.interaction_log).toEqual(log); | |
| expect(out.interaction_log).toHaveLength(2); | |
| }); | |
| it('interaction_log is empty array when no interactions recorded', () => { | |
| const out = buildSessionResult({ ...baseInput, interactionLog: [] }); | |
| expect(out.interaction_log).toEqual([]); | |
| }); | |
| it('interaction_log is independent of analysis result', () => { | |
| const log = [ | |
| { seq: 0, timestamp: '2026-03-18T10:00:00.000Z', type: 'zoom_in' as const, details: {}, correlation_id: 'x' }, | |
| ]; | |
| const out = buildSessionResult({ ...baseInput, result: null, interactionLog: log }); | |
| expect(out.analysis).toBeNull(); | |
| expect(out.interaction_log).toHaveLength(1); | |
| }); | |
| }); | |