Co-Study4Grid / frontend /src /components /ExplorePairsTab.test.tsx
github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
24.6 kB
// 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<string>(),
onToggle: vi.fn(),
onClearSelection: vi.fn(),
preview: null as CombinedAction | null,
simulationFeedback: null,
sessionSimResults: {} as Record<string, { max_rho: number | null; max_rho_line: string; is_rho_reduction: boolean }>,
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(<ExplorePairsTab {...defaultProps} />);
expect(screen.getByText('Selected Actions (0/2)')).toBeInTheDocument();
});
it('renders placeholder text when no actions selected', () => {
render(<ExplorePairsTab {...defaultProps} />);
expect(screen.getByText(/Click rows in the table below to select/)).toBeInTheDocument();
});
it('renders selected chips when actions are selected', () => {
render(<ExplorePairsTab {...defaultProps} selectedIds={new Set(['act1', 'act2'])} />);
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(<ExplorePairsTab {...defaultProps} />);
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(<ExplorePairsTab {...defaultProps} onToggle={onToggle} />);
fireEvent.click(screen.getByText('act1'));
expect(onToggle).toHaveBeenCalledWith('act1');
});
it('shows estimate button when less than 2 actions selected', () => {
render(<ExplorePairsTab {...defaultProps} />);
expect(screen.getByText('Select 2 actions to estimate')).toBeInTheDocument();
});
it('shows enabled estimate button when 2 actions selected', () => {
render(<ExplorePairsTab {...defaultProps} selectedIds={new Set(['act1', 'act2'])} />);
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(<ExplorePairsTab {...defaultProps} selectedIds={new Set(['act1', 'act2'])} hasRestricted={true} />);
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(
<ExplorePairsTab
{...defaultProps}
selectedIds={new Set(['act1', 'act2'])}
hasRestricted={true}
onSimulate={onSimulate}
/>,
);
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(<ExplorePairsTab {...defaultProps} selectedIds={new Set(['act1'])} />);
expect(screen.getByTestId('simulate-combined-button')).toBeDisabled();
});
it('calls onEstimate when estimate button is clicked', () => {
const onEstimate = vi.fn();
render(<ExplorePairsTab {...defaultProps} selectedIds={new Set(['act1', 'act2'])} onEstimate={onEstimate} />);
fireEvent.click(screen.getByTestId('estimate-button'));
expect(onEstimate).toHaveBeenCalled();
});
it('shows estimating message when loading', () => {
render(<ExplorePairsTab {...defaultProps} selectedIds={new Set(['act1', 'act2'])} loading={true} />);
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(<ExplorePairsTab {...defaultProps} selectedIds={new Set(['act1', 'act2'])} preview={preview} />);
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(<ExplorePairsTab {...defaultProps} selectedIds={new Set(['act1', 'act2'])} preview={preview} simulationFeedback={feedback} />);
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(<ExplorePairsTab {...defaultProps} selectedIds={new Set(['act1', 'act2'])} preview={preview} />);
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(<ExplorePairsTab {...defaultProps} selectedIds={new Set(['act1', 'act2'])} preview={preview} simulationFeedback={feedback} />);
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(<ExplorePairsTab {...defaultProps} selectedIds={new Set(['act1', 'act2'])} simulationFeedback={feedback} />);
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(<ExplorePairsTab {...defaultProps} selectedIds={new Set(['act1', 'act2'])} preview={preview} />);
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(<ExplorePairsTab {...defaultProps} selectedIds={new Set(['act1', 'act2'])} simulationFeedback={feedback} />);
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(<ExplorePairsTab {...defaultProps} selectedIds={new Set(['act1', 'act2'])} preview={preview} onSimulate={onSimulate} />);
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(<ExplorePairsTab {...defaultProps} selectedIds={new Set(['act1', 'act2'])} preview={preview} simulating={true} />);
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(<ExplorePairsTab {...defaultProps} selectedIds={new Set(['act1', 'act2'])} simulationFeedback={feedback} />);
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(<ExplorePairsTab {...defaultProps} selectedIds={new Set(['act1', 'act2'])} preview={preview} />);
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(<ExplorePairsTab {...defaultProps} selectedIds={new Set(['act1', 'act2'])} preview={preview} />);
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(<ExplorePairsTab {...defaultProps} selectedIds={new Set(['act1', 'act2'])} preview={preview} />);
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(<ExplorePairsTab {...defaultProps} selectedIds={new Set(['act1', 'act2'])} preview={preview} error="Superposition failed" />);
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(<ExplorePairsTab {...defaultProps} scoredActionsList={[scoredActionsList[0]]} />);
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(<ExplorePairsTab {...defaultProps} selectedIds={new Set(['act1'])} onClearSelection={onClearSelection} />);
fireEvent.click(screen.getByText('Clear All'));
expect(onClearSelection).toHaveBeenCalled();
});
it('shows MW Start values in action rows', () => {
render(<ExplorePairsTab {...defaultProps} />);
expect(screen.getByText('50.0')).toBeInTheDocument();
});
it('shows N/A for null MW Start values', () => {
render(<ExplorePairsTab {...defaultProps} />);
expect(screen.getAllByText('N/A').length).toBeGreaterThan(0);
});
it('shows the empty state when handed an empty scored-actions list', () => {
render(<ExplorePairsTab {...defaultProps} scoredActionsList={[]} />);
expect(screen.getByText(/No scored actions available/)).toBeInTheDocument();
});
it('renders every row it is handed', () => {
render(<ExplorePairsTab {...defaultProps} />);
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(<ExplorePairsTab {...defaultProps} scoredActionsList={injectionList} analysisResult={injectionAnalysis} />);
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(<ExplorePairsTab {...defaultProps} scoredActionsList={injectionList} analysisResult={injectionAnalysis} />);
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(<ExplorePairsTab {...defaultProps} />);
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(<ExplorePairsTab {...defaultProps} scoredActionsList={injectionList} analysisResult={injectionAnalysis} onSimulateSingle={onSimulateSingle} />);
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(<ExplorePairsTab {...defaultProps} onSimulateSingle={onSimulateSingle} />);
// 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(<ExplorePairsTab {...defaultProps} scoredActionsList={curtailList} analysisResult={curtailAnalysis} />);
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(<ExplorePairsTab {...defaultProps} scoredActionsList={curtailList} analysisResult={curtailAnalysis} onSimulateSingle={onSimulateSingle} />);
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(<ExplorePairsTab {...defaultProps} scoredActionsList={injectionList} analysisResult={injectionAnalysis} onSimulateSingle={onSimulateSingle} />);
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(<ExplorePairsTab {...defaultProps} scoredActionsList={lsList} analysisResult={emptyAnalysis} />);
const input = screen.getByTestId('explore-mw-load_shedding_L1') as HTMLInputElement;
expect(input.value).toBe('');
});
});
});