// 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 } from 'vitest'; import { render, screen, fireEvent, within } from '@testing-library/react'; import '@testing-library/jest-dom/vitest'; import ExplorePairsTab from './ExplorePairsTab'; import type { AnalysisResult, CombinedAction } from '../types'; describe('ExplorePairsTab', () => { const scoredActionsList = [ { actionId: 'act1', score: 10, type: 'disco', mwStart: null }, { actionId: 'act2', score: 20, type: 'reco', mwStart: 50.0 }, { actionId: 'act3', score: 15, type: 'disco', mwStart: null }, ]; const mockAnalysisResult: AnalysisResult = { actions: { act1: { description_unitaire: 'D1', max_rho: 0.8, rho_before: [0.8], rho_after: [0.7], max_rho_line: 'L1', is_rho_reduction: true, action_topology: { lines_ex_bus: {}, lines_or_bus: {}, gens_bus: {}, loads_bus: {} } }, act2: { description_unitaire: 'R1', max_rho: 0.9, rho_before: [0.9], rho_after: [0.8], max_rho_line: 'L2', is_rho_reduction: true, action_topology: { lines_ex_bus: {}, lines_or_bus: {}, gens_bus: {}, loads_bus: {} } }, }, lines_overloaded: [], message: 'done', dc_fallback: false, pdf_path: null, pdf_url: null, }; const defaultProps = { scoredActionsList, selectedIds: new Set(), onToggle: vi.fn(), onClearSelection: vi.fn(), preview: null as CombinedAction | null, simulationFeedback: null, sessionSimResults: {} as Record, analysisResult: mockAnalysisResult, loading: false, error: null as string | null, simulating: false, hasRestricted: false, monitoringFactor: 0.95, onEstimate: vi.fn(), onSimulate: vi.fn(), onSimulateSingle: vi.fn(), }; it('renders selection chips header showing 0/2 selected', () => { render(); expect(screen.getByText('Selected Actions (0/2)')).toBeInTheDocument(); }); it('renders placeholder text when no actions selected', () => { render(); expect(screen.getByText(/Click rows in the table below to select/)).toBeInTheDocument(); }); it('renders selected chips when actions are selected', () => { render(); expect(screen.getByTestId('chip-act1')).toBeInTheDocument(); expect(screen.getByTestId('chip-act2')).toBeInTheDocument(); expect(screen.getByText('Selected Actions (2/2)')).toBeInTheDocument(); }); it('renders action rows from scored actions list', () => { render(); expect(screen.getByText('act1')).toBeInTheDocument(); expect(screen.getByText('act2')).toBeInTheDocument(); expect(screen.getByText('act3')).toBeInTheDocument(); }); it('calls onToggle when an action row is clicked', () => { const onToggle = vi.fn(); render(); fireEvent.click(screen.getByText('act1')); expect(onToggle).toHaveBeenCalledWith('act1'); }); it('shows estimate button when less than 2 actions selected', () => { render(); expect(screen.getByText('Select 2 actions to estimate')).toBeInTheDocument(); }); it('shows enabled estimate button when 2 actions selected', () => { render(); expect(screen.getByTestId('estimate-button')).toBeInTheDocument(); expect(screen.getByText('Estimate combination effect')).toBeInTheDocument(); }); it('disables Estimate and surfaces the load-shedding/curtailment caveat when hasRestricted is true', () => { render(); const estimateBtn = screen.getByTestId('estimate-button'); expect(estimateBtn).toBeDisabled(); expect(estimateBtn).toHaveTextContent(/Estimation not available/i); }); it('exposes a Simulate Combined button in the no-preview state that stays enabled even when hasRestricted', () => { const onSimulate = vi.fn(); render( , ); const simBtn = screen.getByTestId('simulate-combined-button'); expect(simBtn).toBeEnabled(); fireEvent.click(simBtn); expect(onSimulate).toHaveBeenCalled(); }); it('disables the no-preview Simulate Combined button until 2 actions are selected', () => { render(); expect(screen.getByTestId('simulate-combined-button')).toBeDisabled(); }); it('calls onEstimate when estimate button is clicked', () => { const onEstimate = vi.fn(); render(); fireEvent.click(screen.getByTestId('estimate-button')); expect(onEstimate).toHaveBeenCalled(); }); it('shows estimating message when loading', () => { render(); expect(screen.getByText(/Estimating Combination/)).toBeInTheDocument(); }); it('renders comparison card when preview is set', () => { const preview: CombinedAction = { action1_id: 'act1', action2_id: 'act2', betas: [1.0, 0.9], max_rho: 0.72, max_rho_line: 'L3', estimated_max_rho: 0.75, estimated_max_rho_line: 'L3', is_rho_reduction: true, description: 'Combined', p_or_combined: [], rho_before: [0.8], rho_after: [0.72], }; render(); expect(screen.getByTestId('comparison-card')).toBeInTheDocument(); expect(screen.getByText('75.0%')).toBeInTheDocument(); expect(screen.getByText(/Estimated Max Loading/)).toBeInTheDocument(); }); it('shows simulation feedback in comparison card', () => { const preview: CombinedAction = { action1_id: 'act1', action2_id: 'act2', betas: [1.0], max_rho: 0.72, max_rho_line: 'L3', is_rho_reduction: true, description: 'Combined', p_or_combined: [], rho_before: [0.8], rho_after: [0.72], }; const feedback = { max_rho: 0.68, max_rho_line: 'L2', is_rho_reduction: true }; render(); expect(screen.getByTestId('simulation-feedback')).toBeInTheDocument(); expect(screen.getByText('68.0%')).toBeInTheDocument(); }); it('shows the Simulate Combined button on top of the card while a preview is shown and no simulation has run yet', () => { const preview: CombinedAction = { action1_id: 'act1', action2_id: 'act2', betas: [1.0], max_rho: 0.72, max_rho_line: 'L3', is_rho_reduction: true, description: 'Combined', p_or_combined: [], rho_before: [0.8], rho_after: [0.72], }; render(); expect(screen.getByTestId('simulate-combined-top-button')).toBeInTheDocument(); }); it('hides the top Simulate Combined button once a simulation result is available', () => { const preview: CombinedAction = { action1_id: 'act1', action2_id: 'act2', betas: [1.0], max_rho: 0.72, max_rho_line: 'L3', is_rho_reduction: true, description: 'Combined', p_or_combined: [], rho_before: [0.8], rho_after: [0.72], }; const feedback = { max_rho: 0.68, max_rho_line: 'L2', is_rho_reduction: true }; render(); expect(screen.queryByTestId('simulate-combined-top-button')).not.toBeInTheDocument(); }); it('renders the card with only the Simulation Result column when simulation ran without an estimate', () => { const feedback = { max_rho: 0.68, max_rho_line: 'L2', is_rho_reduction: true }; render(); expect(screen.getByTestId('comparison-card')).toBeInTheDocument(); expect(screen.getByTestId('simulation-feedback')).toBeInTheDocument(); expect(screen.queryByText(/Estimated Max Loading/)).not.toBeInTheDocument(); }); it('does not render Estimate / Simulate buttons inside the comparison card header', () => { const preview: CombinedAction = { action1_id: 'act1', action2_id: 'act2', betas: [1.0], max_rho: 0.72, max_rho_line: 'L3', is_rho_reduction: true, description: 'Combined', p_or_combined: [], rho_before: [0.8], rho_after: [0.72], }; render(); const card = screen.getByTestId('comparison-card'); expect(within(card).queryByText('Estimate combination effect')).not.toBeInTheDocument(); expect(within(card).queryByText('Estimation unavailable')).not.toBeInTheDocument(); expect(within(card).queryByText('Simulate Combined')).not.toBeInTheDocument(); }); it('hides the bottom action bar once a simulation has run (no preview path)', () => { const feedback = { max_rho: 0.68, max_rho_line: 'L2', is_rho_reduction: true }; render(); expect(screen.queryByTestId('estimate-button')).not.toBeInTheDocument(); expect(screen.queryByTestId('simulate-combined-button')).not.toBeInTheDocument(); }); it('clicking the top Simulate Combined button calls onSimulate', () => { const onSimulate = vi.fn(); const preview: CombinedAction = { action1_id: 'act1', action2_id: 'act2', betas: [1.0], max_rho: 0.72, max_rho_line: 'L3', is_rho_reduction: true, description: 'Combined', p_or_combined: [], rho_before: [0.8], rho_after: [0.72], }; render(); fireEvent.click(screen.getByTestId('simulate-combined-top-button')); expect(onSimulate).toHaveBeenCalled(); }); it('disables the top Simulate Combined button while simulating', () => { const preview: CombinedAction = { action1_id: 'act1', action2_id: 'act2', betas: [1.0], max_rho: 0.72, max_rho_line: 'L3', is_rho_reduction: true, description: 'Combined', p_or_combined: [], rho_before: [0.8], rho_after: [0.72], }; render(); expect(screen.getByTestId('simulate-combined-top-button')).toBeDisabled(); }); it('uses the "Simulation Result" title when only simulation ran (no estimate)', () => { const feedback = { max_rho: 0.68, max_rho_line: 'L2', is_rho_reduction: true }; render(); const card = screen.getByTestId('comparison-card'); // "Simulation Result" appears as both the card title and the column header — so we expect 2 matches expect(within(card).getAllByText('Simulation Result').length).toBe(2); expect(within(card).queryByText('Explore Pairs Comparison')).not.toBeInTheDocument(); }); it('surfaces target_max_rho in the comparison card when it differs from the global estimated line', () => { const preview: CombinedAction = { action1_id: 'act1', action2_id: 'act2', betas: [1.5, 0.5], max_rho: 0.82, max_rho_line: 'LOUHAL31PYMON', estimated_max_rho: 0.82, estimated_max_rho_line: 'LOUHAL31PYMON', target_max_rho: 0.20, target_max_rho_line: 'BEON L31CPVAN', is_rho_reduction: false, description: 'Combined', p_or_combined: [], rho_before: [0.8], rho_after: [0.20], }; render(); const target = screen.getByTestId('target-max-rho'); expect(target).toHaveTextContent(/20\.0%.*BEON L31CPVAN/); }); it('hides target_max_rho when it matches the global estimated line (nothing to add)', () => { const preview: CombinedAction = { action1_id: 'act1', action2_id: 'act2', betas: [1.0, 0.9], max_rho: 0.72, max_rho_line: 'L3', estimated_max_rho: 0.72, estimated_max_rho_line: 'L3', target_max_rho: 0.72, target_max_rho_line: 'L3', is_rho_reduction: true, description: 'Combined', p_or_combined: [], rho_before: [0.8], rho_after: [0.72], }; render(); expect(screen.queryByTestId('target-max-rho')).not.toBeInTheDocument(); }); it('uses the "Explore Pairs Comparison" title when an estimate is present', () => { const preview: CombinedAction = { action1_id: 'act1', action2_id: 'act2', betas: [1.0], max_rho: 0.72, max_rho_line: 'L3', is_rho_reduction: true, description: 'Combined', p_or_combined: [], rho_before: [0.8], rho_after: [0.72], }; render(); const card = screen.getByTestId('comparison-card'); expect(within(card).getByText('Explore Pairs Comparison')).toBeInTheDocument(); }); it('shows error message in comparison card', () => { const preview: CombinedAction = { action1_id: 'act1', action2_id: 'act2', betas: [1.0], max_rho: 0.72, max_rho_line: 'L3', is_rho_reduction: true, description: 'Combined', p_or_combined: [], rho_before: [0.8], rho_after: [0.72], }; render(); expect(screen.getByText('Superposition failed')).toBeInTheDocument(); }); // The action-type / severity filtering moved OUT of this tab — it // is now driven by the shared ActionFilterRings in the Combine-modal // header, which hands ExplorePairsTab an already-filtered // `scoredActionsList`. The tab itself only renders what it is given. it('renders exactly the rows it is handed (no in-tab filtering)', () => { render(); expect(screen.getByText('act1')).toBeInTheDocument(); expect(screen.queryByText('act2')).not.toBeInTheDocument(); expect(screen.queryByText('act3')).not.toBeInTheDocument(); }); it('calls onClearSelection when Clear All is clicked', () => { const onClearSelection = vi.fn(); render(); fireEvent.click(screen.getByText('Clear All')); expect(onClearSelection).toHaveBeenCalled(); }); it('shows MW Start values in action rows', () => { render(); expect(screen.getByText('50.0')).toBeInTheDocument(); }); it('shows N/A for null MW Start values', () => { render(); expect(screen.getAllByText('N/A').length).toBeGreaterThan(0); }); it('shows the empty state when handed an empty scored-actions list', () => { render(); expect(screen.getByText(/No scored actions available/)).toBeInTheDocument(); }); it('renders every row it is handed', () => { render(); expect(screen.getByText('act1')).toBeInTheDocument(); expect(screen.getByText('act2')).toBeInTheDocument(); expect(screen.getByText('act3')).toBeInTheDocument(); }); // Injection actions (load shedding / curtailment / redispatch) expose an // editable MW setpoint per row so the operator can tune the target shed // amount or the signed redispatch delta before simulating — parity with // the manual-selection table and the action card. describe('editable injection setpoint', () => { const injectionList = [ { actionId: 'load_shedding_L1', score: 22, type: 'load_shedding', mwStart: 22.0 }, { actionId: 'redispatch_G1', score: 1.0, type: 'redispatch', mwStart: 14.3 }, ]; const injectionAnalysis: AnalysisResult = { actions: { redispatch_G1: { description_unitaire: 'redispatch G1', max_rho: 0.9, rho_before: [0.9], rho_after: [0.8], max_rho_line: 'L1', is_rho_reduction: true, redispatch_details: [{ gen_name: 'G1', voltage_level_id: 'VL1', delta_mw: 10.0, target_mw: 24.3, direction: 'up', current_mw: 14.3, max_raise_mw: 30.0, max_lower_mw: 14.3 }], }, }, lines_overloaded: [], message: 'done', dc_fallback: false, pdf_path: null, pdf_url: null, }; it('renders an editable input for load-shedding and redispatch rows', () => { render(); expect(screen.getByTestId('explore-mw-load_shedding_L1')).toBeInTheDocument(); expect(screen.getByTestId('explore-mw-redispatch_G1')).toBeInTheDocument(); }); it('defaults the redispatch input to the simulated delta_mw', () => { render(); const input = screen.getByTestId('explore-mw-redispatch_G1') as HTMLInputElement; expect(input.value).toBe('10.0'); }); it('does not render an editable input for non-injection (disco/reco) rows', () => { render(); expect(screen.queryByTestId('explore-mw-act1')).not.toBeInTheDocument(); expect(screen.queryByTestId('explore-mw-act2')).not.toBeInTheDocument(); }); it('passes the edited target MW to onSimulateSingle when the row button is clicked', () => { const onSimulateSingle = vi.fn(); render(); fireEvent.change(screen.getByTestId('explore-mw-load_shedding_L1'), { target: { value: '12' } }); // The LS action is untested → button reads "Simulate". const row = screen.getByText('load_shedding_L1').closest('tr')!; fireEvent.click(within(row).getByText('Simulate')); expect(onSimulateSingle).toHaveBeenCalledWith('load_shedding_L1', 12); }); it('passes undefined target MW for a non-injection row (recommender default)', () => { const onSimulateSingle = vi.fn(); render(); // act1 (disco) is pre-simulated in the mock → button reads "Re-run". const row = screen.getByText('act1').closest('tr')!; fireEvent.click(within(row).getByText('Re-run')); expect(onSimulateSingle).toHaveBeenCalledWith('act1', undefined); }); it('renders an editable input for curtailment rows and defaults to curtailed_mw', () => { const curtailList = [ { actionId: 'curtail_G1', score: 3.0, type: 'renewable_curtailment', mwStart: 18.0 }, ]; const curtailAnalysis: AnalysisResult = { actions: { curtail_G1: { description_unitaire: 'curtail G1', max_rho: 0.7, rho_before: [0.8], rho_after: [0.7], max_rho_line: 'L1', is_rho_reduction: true, curtailment_details: [{ gen_name: 'G1', voltage_level_id: 'VL2', curtailed_mw: 8.5 }], }, }, lines_overloaded: [], message: 'done', dc_fallback: false, pdf_path: null, pdf_url: null, }; render(); const input = screen.getByTestId('explore-mw-curtail_G1') as HTMLInputElement; expect(input.value).toBe('8.5'); }); it('passes the edited curtailment target MW to onSimulateSingle', () => { const onSimulateSingle = vi.fn(); const curtailList = [ { actionId: 'curtail_G1', score: 3.0, type: 'renewable_curtailment', mwStart: 18.0 }, ]; const curtailAnalysis: AnalysisResult = { actions: {}, lines_overloaded: [], message: 'done', dc_fallback: false, pdf_path: null, pdf_url: null, }; render(); fireEvent.change(screen.getByTestId('explore-mw-curtail_G1'), { target: { value: '5.5' } }); const row = screen.getByText('curtail_G1').closest('tr')!; fireEvent.click(within(row).getByText('Simulate')); expect(onSimulateSingle).toHaveBeenCalledWith('curtail_G1', 5.5); }); it('uses the stored delta_mw when no user edit has been made (redispatch)', () => { const onSimulateSingle = vi.fn(); render(); const row = screen.getByText('redispatch_G1').closest('tr')!; fireEvent.click(within(row).getByText('Re-run')); expect(onSimulateSingle).toHaveBeenCalledWith('redispatch_G1', 10.0); }); it('leaves the LS input empty when no simulation result exists yet', () => { const lsList = [ { actionId: 'load_shedding_L1', score: 22, type: 'load_shedding', mwStart: 22.0 }, ]; const emptyAnalysis: AnalysisResult = { actions: {}, lines_overloaded: [], message: 'done', dc_fallback: false, pdf_path: null, pdf_url: null, }; render(); const input = screen.getByTestId('explore-mw-load_shedding_L1') as HTMLInputElement; expect(input.value).toBe(''); }); }); });