// 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(); expect(container.firstChild).toBeNull(); }); it('renders Paths tab by default', () => { render(); expect(screen.getByLabelText('Network File Path (.xiidm)')).toBeInTheDocument(); expect(screen.getByDisplayValue('/net.xiidm')).toBeInTheDocument(); }); it('switches tabs correctly', () => { render(); 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(); 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(); fireEvent.change(screen.getByLabelText('Min Line Reconnections'), { target: { value: '2.5' } }); expect(mockSettings.setMinLineReconnections).toHaveBeenCalledWith(2.5); unmount(); // Checkbox input render(); fireEvent.click(screen.getByLabelText('Ignore Reconnections')); expect(mockSettings.setIgnoreReconnections).toHaveBeenCalledWith(true); }); it('calls apply and close callbacks', () => { render(); 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(); 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) => ({ ...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(); 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(); 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(); 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(); 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(); 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(); 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( ); 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(); 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(); fireEvent.click(screen.getByText('Paths')); expect(mockSettings.setSettingsTab).toHaveBeenCalledWith('paths'); }); }); });