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, 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'); | |
| }); | |
| }); | |
| }); | |