Co-Study4Grid / frontend /src /components /modals /SettingsModal.test.tsx
github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
13.2 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, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import SettingsModal from './SettingsModal';
import { SettingsState } from '../../hooks/useSettings';
import { interactionLogger } from '../../utils/interactionLogger';
describe('SettingsModal', () => {
beforeEach(() => {
vi.clearAllMocks();
interactionLogger.clear();
});
const mockSettings = {
isSettingsOpen: true,
settingsTab: 'paths',
setSettingsTab: vi.fn(),
networkPath: '/net.xiidm',
setNetworkPath: vi.fn(),
actionPath: '/act.json',
setActionPath: vi.fn(),
layoutPath: '/lay.json',
setLayoutPath: vi.fn(),
outputFolderPath: '/out',
setOutputFolderPath: vi.fn(),
configFilePath: '/conf.json',
setConfigFilePath: vi.fn(),
changeConfigFilePath: vi.fn().mockResolvedValue(undefined),
minLineReconnections: 1.0,
setMinLineReconnections: vi.fn(),
minCloseCoupling: 1.0,
setMinCloseCoupling: vi.fn(),
minOpenCoupling: 1.0,
setMinOpenCoupling: vi.fn(),
minLineDisconnections: 1.0,
setMinLineDisconnections: vi.fn(),
nPrioritizedActions: 5,
setNPrioritizedActions: vi.fn(),
minPst: 1.0,
setMinPst: vi.fn(),
minLoadShedding: 0.0,
setMinLoadShedding: vi.fn(),
minRenewableCurtailmentActions: 0.0,
setMinRenewableCurtailmentActions: vi.fn(),
minRedispatch: 0.0,
setMinRedispatch: vi.fn(),
allowedActionTypes: [],
setAllowedActionTypes: vi.fn(),
ignoreReconnections: false,
setIgnoreReconnections: vi.fn(),
monitoringFactor: 0.95,
setMonitoringFactor: vi.fn(),
linesMonitoringPath: '',
setLinesMonitoringPath: vi.fn(),
preExistingOverloadThreshold: 0.02,
setPreExistingOverloadThreshold: vi.fn(),
pypowsyblFastMode: true,
setPypowsyblFastMode: vi.fn(),
pickSettingsPath: vi.fn(),
handleCloseSettings: vi.fn(),
} as unknown as SettingsState;
const defaultProps = {
settings: mockSettings,
onApply: vi.fn(),
};
it('returns null when isSettingsOpen is false', () => {
const { container } = render(<SettingsModal {...defaultProps} settings={{ ...mockSettings, isSettingsOpen: false } as unknown as SettingsState} />);
expect(container.firstChild).toBeNull();
});
it('renders Paths tab by default', () => {
render(<SettingsModal {...defaultProps} />);
expect(screen.getByLabelText('Network File Path (.xiidm)')).toBeInTheDocument();
expect(screen.getByDisplayValue('/net.xiidm')).toBeInTheDocument();
});
it('switches tabs correctly', () => {
render(<SettingsModal {...defaultProps} />);
fireEvent.click(screen.getByText('Recommender'));
expect(mockSettings.setSettingsTab).toHaveBeenCalledWith('recommender');
fireEvent.click(screen.getByText('Configurations'));
expect(mockSettings.setSettingsTab).toHaveBeenCalledWith('configurations');
});
it('calls setters on input change', () => {
// Paths tab input
render(<SettingsModal {...defaultProps} />);
fireEvent.change(screen.getByLabelText('Network File Path (.xiidm)'), { target: { value: '/new.xiidm' } });
expect(mockSettings.setNetworkPath).toHaveBeenCalledWith('/new.xiidm');
// Recommender tab input (requires tab switch in real app, but here we can mock render with that tab)
const { unmount } = render(<SettingsModal {...defaultProps} settings={{ ...mockSettings, settingsTab: 'recommender' } as unknown as SettingsState} />);
fireEvent.change(screen.getByLabelText('Min Line Reconnections'), { target: { value: '2.5' } });
expect(mockSettings.setMinLineReconnections).toHaveBeenCalledWith(2.5);
unmount();
// Checkbox input
render(<SettingsModal {...defaultProps} settings={{ ...mockSettings, settingsTab: 'recommender' } as unknown as SettingsState} />);
fireEvent.click(screen.getByLabelText('Ignore Reconnections'));
expect(mockSettings.setIgnoreReconnections).toHaveBeenCalledWith(true);
});
it('calls apply and close callbacks', () => {
render(<SettingsModal {...defaultProps} />);
fireEvent.click(screen.getByText('Apply'));
expect(defaultProps.onApply).toHaveBeenCalled();
fireEvent.click(screen.getByText('Close'));
expect(mockSettings.handleCloseSettings).toHaveBeenCalled();
});
it('calls pickSettingsPath on file icon click', () => {
render(<SettingsModal {...defaultProps} />);
const pickButtons = screen.getAllByText('📄');
fireEvent.click(pickButtons[0]);
expect(mockSettings.pickSettingsPath).toHaveBeenCalled();
});
// =====================================================================
// Model-driven param filtering + degraded-fallback robustness
// =====================================================================
describe('recommender param visibility', () => {
const recommenderSettings = (overrides: Record<string, unknown>) => ({
...mockSettings,
settingsTab: 'recommender',
recommenderModel: 'expert',
setRecommenderModel: vi.fn(),
computeOverflowGraph: true,
setComputeOverflowGraph: vi.fn(),
...overrides,
} as unknown as SettingsState);
it('renders the Min Redispatch field when the active model declares it', () => {
const settings = recommenderSettings({
availableModels: [{
name: 'expert', label: 'Expert system',
requires_overflow_graph: true, is_default: true,
params: [
{ name: 'min_redispatch', label: 'Min Redispatching', kind: 'int' },
{ name: 'min_load_shedding', label: 'Min Load Shedding', kind: 'int' },
],
}],
});
render(<SettingsModal {...defaultProps} settings={settings} />);
expect(screen.getByLabelText('Min Redispatch')).toBeInTheDocument();
expect(screen.getByLabelText('Min Load Shedding')).toBeInTheDocument();
// Undeclared field must stay hidden (dynamic filtering still works).
expect(screen.queryByLabelText('Min PST Actions')).not.toBeInTheDocument();
});
it('REGRESSION: a model that declares NO params (degraded /api/models fallback) shows ALL fields, not none', () => {
// When /api/models errors the frontend serves a static
// {name:'expert', params:[]} fallback. Without the empty-params
// guard this blanked out every parameter in the tab.
const settings = recommenderSettings({
availableModels: [{
name: 'expert', label: 'Expert system',
requires_overflow_graph: true, is_default: true,
params: [],
}],
});
render(<SettingsModal {...defaultProps} settings={settings} />);
for (const label of [
'Min Line Reconnections', 'Min Close Coupling', 'Min Open Coupling',
'Min Line Disconnections', 'Min PST Actions', 'Min Load Shedding',
'Min Renewable Curtailment', 'Min Redispatch',
]) {
expect(screen.getByLabelText(label)).toBeInTheDocument();
}
});
it('toggles an allowed-action-type to restrict the recommender', () => {
const setAllowedActionTypes = vi.fn();
const settings = recommenderSettings({ allowedActionTypes: [], setAllowedActionTypes });
render(<SettingsModal {...defaultProps} settings={settings} />);
const chip = screen.getByTestId('allowed-type-redispatch');
expect(chip).toHaveAttribute('aria-pressed', 'false');
fireEvent.click(chip);
expect(setAllowedActionTypes).toHaveBeenCalledWith(['redispatch']);
});
it('removes an already-selected allowed-action-type', () => {
const setAllowedActionTypes = vi.fn();
const settings = recommenderSettings({ allowedActionTypes: ['redispatch'], setAllowedActionTypes });
render(<SettingsModal {...defaultProps} settings={settings} />);
const chip = screen.getByTestId('allowed-type-redispatch');
expect(chip).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(chip);
expect(setAllowedActionTypes).toHaveBeenCalledWith([]);
});
});
// =====================================================================
// settings_tab_changed interaction log shape
// =====================================================================
//
// The replay contract requires { from_tab, to_tab } so an agent can
// assert the modal was in the expected tab before clicking the new
// one. Before this fix the logger emitted only { tab } (the
// destination), which lost the "where was the user coming from?"
// information needed for verification.
// ---------------------------------------------------------------------
describe('settings_tab_changed interaction log shape', () => {
it('logs { from_tab, to_tab } when switching from paths → recommender', () => {
render(<SettingsModal {...defaultProps} />);
fireEvent.click(screen.getByText('Recommender'));
const log = interactionLogger.getLog();
const tabChange = log.find(e => e.type === 'settings_tab_changed');
expect(tabChange).toBeDefined();
expect(tabChange!.details).toEqual({ from_tab: 'paths', to_tab: 'recommender' });
});
it('logs { from_tab, to_tab } when switching from paths → configurations', () => {
render(<SettingsModal {...defaultProps} />);
fireEvent.click(screen.getByText('Configurations'));
const log = interactionLogger.getLog();
const tabChange = log.find(e => e.type === 'settings_tab_changed');
expect(tabChange).toBeDefined();
expect(tabChange!.details).toEqual({ from_tab: 'paths', to_tab: 'configurations' });
});
it('records from_tab as the currently-active tab, not the initial one', () => {
// Re-render the modal already on the "recommender" tab and
// click "configurations": the from_tab must be 'recommender'.
render(
<SettingsModal
{...defaultProps}
settings={{ ...mockSettings, settingsTab: 'recommender' } as unknown as SettingsState}
/>
);
fireEvent.click(screen.getByText('Configurations'));
const log = interactionLogger.getLog();
const tabChange = log.find(e => e.type === 'settings_tab_changed');
expect(tabChange).toBeDefined();
expect(tabChange!.details).toEqual({ from_tab: 'recommender', to_tab: 'configurations' });
});
it('does NOT log when the user clicks the already-active tab (no-op skip)', () => {
// Clicking the already-active tab is a UI no-op — it must
// not pollute the log with empty transitions that a replay
// agent would have to filter out. Regression guard.
render(<SettingsModal {...defaultProps} />);
fireEvent.click(screen.getByText('Paths'));
const log = interactionLogger.getLog();
expect(log.filter(e => e.type === 'settings_tab_changed')).toHaveLength(0);
// And the setter must still not have been called with the
// same value (idempotency in practice is fine, but we log
// only real transitions).
});
it('still calls setSettingsTab even on a no-op click (setter is unconditional)', () => {
// The setter is invoked for every click so React state
// remains consistent with the DOM — only the logger is
// gated on "actually changed". This test pins that exact
// split behaviour.
render(<SettingsModal {...defaultProps} />);
fireEvent.click(screen.getByText('Paths'));
expect(mockSettings.setSettingsTab).toHaveBeenCalledWith('paths');
});
});
});