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 } from 'vitest'; | |
| import { render, screen, fireEvent } from '@testing-library/react'; | |
| import '@testing-library/jest-dom/vitest'; | |
| import { createRef } from 'react'; | |
| import ActionSearchDropdown from './ActionSearchDropdown'; | |
| import { DEFAULT_ACTION_OVERVIEW_FILTERS } from '../utils/actionTypes'; | |
| import type { ActionDetail, ActionOverviewFilters } from '../types'; | |
| describe('ActionSearchDropdown', () => { | |
| const defaultProps = { | |
| dropdownRef: createRef<HTMLDivElement>(), | |
| searchInputRef: createRef<HTMLInputElement>(), | |
| searchQuery: '', | |
| onSearchQueryChange: vi.fn(), | |
| filters: DEFAULT_ACTION_OVERVIEW_FILTERS as ActionOverviewFilters, | |
| onFiltersChange: vi.fn(), | |
| error: null as string | null, | |
| loadingActions: false, | |
| scoredActionsList: [] as { type: string; actionId: string; score: number; mwStart: number | null }[], | |
| filteredActions: [] as { id: string; description: string; type?: string }[], | |
| actionScores: undefined as Record<string, Record<string, unknown>> | undefined, | |
| actions: {} as Record<string, ActionDetail>, | |
| cardEditMw: {} as Record<string, string>, | |
| onCardEditMwChange: vi.fn(), | |
| cardEditTap: {} as Record<string, string>, | |
| onCardEditTapChange: vi.fn(), | |
| simulating: null as string | null, | |
| resimulating: null as string | null, | |
| onAddAction: vi.fn(), | |
| onResimulate: vi.fn(), | |
| onResimulateTap: vi.fn(), | |
| onShowTooltip: vi.fn(), | |
| onHideTooltip: vi.fn(), | |
| monitoringFactor: 0.95, | |
| }; | |
| it('renders search input with placeholder', () => { | |
| render(<ActionSearchDropdown {...defaultProps} />); | |
| expect(screen.getByPlaceholderText('Search action by ID or description...')).toBeInTheDocument(); | |
| }); | |
| it('renders the shared ActionFilterRings (severity + action-type rings)', () => { | |
| render(<ActionSearchDropdown {...defaultProps} />); | |
| expect(screen.getByTestId('sidebar-action-filters')).toBeInTheDocument(); | |
| for (const t of ['disco', 'reco', 'ls', 'rc', 'pst', 'open', 'close']) { | |
| expect(screen.getByTestId(`sidebar-filter-type-${t}`)).toBeInTheDocument(); | |
| } | |
| for (const c of ['green', 'orange', 'red', 'grey']) { | |
| expect(screen.getByTestId(`sidebar-filter-category-${c}`)).toBeInTheDocument(); | |
| } | |
| }); | |
| it('calls onFiltersChange when an action-type ring toggle is clicked', () => { | |
| const onFiltersChange = vi.fn(); | |
| render(<ActionSearchDropdown {...defaultProps} onFiltersChange={onFiltersChange} />); | |
| fireEvent.click(screen.getByTestId('sidebar-filter-type-pst')); | |
| expect(onFiltersChange).toHaveBeenCalledWith( | |
| expect.objectContaining({ actionType: 'pst' }), | |
| ); | |
| }); | |
| it('marks the active action-type toggle with aria-pressed="true"', () => { | |
| render( | |
| <ActionSearchDropdown | |
| {...defaultProps} | |
| filters={{ ...DEFAULT_ACTION_OVERVIEW_FILTERS, actionType: 'disco' }} | |
| />, | |
| ); | |
| expect(screen.getByTestId('sidebar-filter-type-disco').getAttribute('aria-pressed')).toBe('true'); | |
| expect(screen.getByTestId('sidebar-filter-type-pst').getAttribute('aria-pressed')).toBe('false'); | |
| }); | |
| it('calls onSearchQueryChange when input changes', () => { | |
| const onSearchQueryChange = vi.fn(); | |
| render(<ActionSearchDropdown {...defaultProps} onSearchQueryChange={onSearchQueryChange} />); | |
| fireEvent.change(screen.getByPlaceholderText('Search action by ID or description...'), { target: { value: 'test' } }); | |
| expect(onSearchQueryChange).toHaveBeenCalledWith('test'); | |
| }); | |
| it('displays error message when error is set', () => { | |
| render(<ActionSearchDropdown {...defaultProps} error="Something went wrong" />); | |
| expect(screen.getByText('Something went wrong')).toBeInTheDocument(); | |
| }); | |
| it('shows loading message when loadingActions is true', () => { | |
| render(<ActionSearchDropdown {...defaultProps} loadingActions={true} />); | |
| expect(screen.getByText('Loading actions...')).toBeInTheDocument(); | |
| }); | |
| it('shows "All actions already added" when no actions available and no query', () => { | |
| render(<ActionSearchDropdown {...defaultProps} />); | |
| expect(screen.getByText('All actions already added')).toBeInTheDocument(); | |
| }); | |
| it('renders filtered actions list', () => { | |
| const filteredActions = [ | |
| { id: 'reco_1', description: 'Close line L1' }, | |
| { id: 'reco_2', description: 'Close line L2' }, | |
| ]; | |
| render(<ActionSearchDropdown {...defaultProps} searchQuery="reco" filteredActions={filteredActions} />); | |
| expect(screen.getByText('reco_1')).toBeInTheDocument(); | |
| expect(screen.getByText('reco_2')).toBeInTheDocument(); | |
| }); | |
| it('calls onAddAction when an action item is clicked', () => { | |
| const onAddAction = vi.fn(); | |
| const filteredActions = [{ id: 'reco_1', description: 'Close line L1' }]; | |
| render(<ActionSearchDropdown {...defaultProps} searchQuery="reco" filteredActions={filteredActions} onAddAction={onAddAction} />); | |
| fireEvent.click(screen.getByText('reco_1')); | |
| expect(onAddAction).toHaveBeenCalledWith('reco_1'); | |
| }); | |
| it('shows manual ID option when searchQuery does not match any filtered action', () => { | |
| render(<ActionSearchDropdown {...defaultProps} searchQuery="my_custom_action" />); | |
| expect(screen.getByTestId('manual-id-option-my_custom_action')).toBeInTheDocument(); | |
| expect(screen.getByText('my_custom_action')).toBeInTheDocument(); | |
| }); | |
| it('calls onAddAction with manual ID when manual option is clicked', () => { | |
| const onAddAction = vi.fn(); | |
| render(<ActionSearchDropdown {...defaultProps} searchQuery="manual_42" onAddAction={onAddAction} />); | |
| fireEvent.click(screen.getByTestId('manual-id-option-manual_42')); | |
| expect(onAddAction).toHaveBeenCalledWith('manual_42'); | |
| }); | |
| it('renders scored actions table when scored actions exist and no search query', () => { | |
| const scoredActionsList = [ | |
| { type: 'line_reconnection', actionId: 'act_1', score: 10, mwStart: null }, | |
| ]; | |
| const actionScores = { | |
| line_reconnection: { scores: { act_1: 10 }, params: {} }, | |
| }; | |
| render(<ActionSearchDropdown {...defaultProps} scoredActionsList={scoredActionsList} actionScores={actionScores} />); | |
| expect(screen.getByText('Scored Actions')).toBeInTheDocument(); | |
| expect(screen.getByText('act_1')).toBeInTheDocument(); | |
| expect(screen.getByText('10.00')).toBeInTheDocument(); | |
| }); | |
| it('shows simulating state on action items', () => { | |
| const filteredActions = [{ id: 'act_sim', description: 'Test Action' }]; | |
| render(<ActionSearchDropdown {...defaultProps} searchQuery="act" filteredActions={filteredActions} simulating="act_sim" />); | |
| expect(screen.getByText('Simulating...')).toBeInTheDocument(); | |
| }); | |
| describe('wide mode (centered overlay when scoring is computed)', () => { | |
| it('renders the narrow inline dropdown by default', () => { | |
| render(<ActionSearchDropdown {...defaultProps} />); | |
| expect(screen.getByTestId('manual-selection-dropdown')).toBeInTheDocument(); | |
| expect(screen.queryByTestId('manual-selection-wide')).not.toBeInTheDocument(); | |
| expect(screen.queryByTestId('manual-selection-backdrop')).not.toBeInTheDocument(); | |
| }); | |
| it('renders a wide centered overlay with backdrop when wide=true', () => { | |
| render(<ActionSearchDropdown {...defaultProps} wide />); | |
| expect(screen.queryByTestId('manual-selection-dropdown')).not.toBeInTheDocument(); | |
| const wide = screen.getByTestId('manual-selection-wide'); | |
| expect(wide).toBeInTheDocument(); | |
| // Mirrors the Combine Actions modal layout: centered, fixed, | |
| // 80vw wide so the score table has room for its columns. | |
| const styleAttr = wide.getAttribute('style') || ''; | |
| expect(styleAttr).toContain('position: fixed'); | |
| expect(styleAttr).toContain('width: 80vw'); | |
| expect(screen.getByTestId('manual-selection-backdrop')).toBeInTheDocument(); | |
| }); | |
| it('anchors the wide overlay to a fixed viewport top (no vertical re-centering as content grows)', () => { | |
| // Operators picking a chip that filters the score table down | |
| // to zero rows (or back up) used to see the modal hop | |
| // vertically: ``top: 50% / translate(-50%, -50%)`` re-centers | |
| // on the body's new height. The fix anchors the top of the | |
| // modal to a fixed viewport offset so the title + filter | |
| // header stay at the same screen height regardless of the | |
| // table's row count. | |
| render(<ActionSearchDropdown {...defaultProps} wide />); | |
| const styleAttr = screen.getByTestId('manual-selection-wide').getAttribute('style') || ''; | |
| expect(styleAttr).toContain('top: 7.5vh'); | |
| expect(styleAttr).toContain('translateX(-50%)'); | |
| expect(styleAttr).not.toContain('translate(-50%, -50%)'); | |
| }); | |
| it('still renders the score table inside the wide overlay', () => { | |
| const scoredActionsList = [ | |
| { type: 'line_reconnection', actionId: 'act_wide_1', score: 7.25, mwStart: null }, | |
| ]; | |
| const actionScores = { | |
| line_reconnection: { scores: { act_wide_1: 7.25 }, params: {} }, | |
| }; | |
| render( | |
| <ActionSearchDropdown | |
| {...defaultProps} | |
| scoredActionsList={scoredActionsList} | |
| actionScores={actionScores} | |
| wide | |
| />, | |
| ); | |
| expect(screen.getByTestId('manual-selection-wide')).toBeInTheDocument(); | |
| expect(screen.getByText('Scored Actions')).toBeInTheDocument(); | |
| expect(screen.getByText('act_wide_1')).toBeInTheDocument(); | |
| expect(screen.getByText('7.25')).toBeInTheDocument(); | |
| }); | |
| }); | |
| describe('Target MW sync (Bug 1)', () => { | |
| // A computed load-shedding action is already in the `actions` map | |
| // with a simulated shedded_mw value. The score table row for that | |
| // action must display the simulated value by default instead of an | |
| // empty input, so the user can see what the action was run with. | |
| it('populates LS score-table input with stored shedded_mw for computed actions', () => { | |
| const scoredActionsList = [ | |
| { type: 'load_shedding', actionId: 'load_shedding_L1', score: 1.0, mwStart: 6.4 }, | |
| ]; | |
| const actionScores = { | |
| load_shedding: { scores: { load_shedding_L1: 1.0 }, params: {} }, | |
| }; | |
| const actions: Record<string, ActionDetail> = { | |
| load_shedding_L1: { | |
| description_unitaire: 'shed L1', | |
| rho_before: null, | |
| rho_after: null, | |
| max_rho: 0.5, | |
| max_rho_line: 'LINE_A', | |
| is_rho_reduction: true, | |
| load_shedding_details: [ | |
| { load_name: 'L1', voltage_level_id: 'VL1', shedded_mw: 5.4 }, | |
| ], | |
| }, | |
| }; | |
| render( | |
| <ActionSearchDropdown | |
| {...defaultProps} | |
| scoredActionsList={scoredActionsList} | |
| actionScores={actionScores} | |
| actions={actions} | |
| />, | |
| ); | |
| const input = screen.getByTestId('target-mw-load_shedding_L1') as HTMLInputElement; | |
| expect(input.value).toBe('5.4'); | |
| }); | |
| // Same guarantee for renewable curtailment: the stored curtailed_mw | |
| // must show up as the default value for computed rows. | |
| it('populates RC score-table input with stored curtailed_mw for computed actions', () => { | |
| const scoredActionsList = [ | |
| { type: 'renewable_curtailment', actionId: 'curtail_G1', score: 1.0, mwStart: 8.0 }, | |
| ]; | |
| const actionScores = { | |
| renewable_curtailment: { scores: { curtail_G1: 1.0 }, params: {} }, | |
| }; | |
| const actions: Record<string, ActionDetail> = { | |
| curtail_G1: { | |
| description_unitaire: 'curtail G1', | |
| rho_before: null, | |
| rho_after: null, | |
| max_rho: 0.6, | |
| max_rho_line: 'LINE_B', | |
| is_rho_reduction: true, | |
| curtailment_details: [ | |
| { gen_name: 'G1', voltage_level_id: 'VL2', curtailed_mw: 3.1 }, | |
| ], | |
| }, | |
| }; | |
| render( | |
| <ActionSearchDropdown | |
| {...defaultProps} | |
| scoredActionsList={scoredActionsList} | |
| actionScores={actionScores} | |
| actions={actions} | |
| />, | |
| ); | |
| const input = screen.getByTestId('target-mw-curtail_G1') as HTMLInputElement; | |
| expect(input.value).toBe('3.1'); | |
| }); | |
| // The cardEditMw value (written when the user edits the input on the | |
| // prioritized action card) must be reflected in the score table row | |
| // input, so the two UIs stay synchronized. | |
| it('mirrors cardEditMw value in the score-table input', () => { | |
| const onCardEditMwChange = vi.fn(); | |
| const scoredActionsList = [ | |
| { type: 'load_shedding', actionId: 'load_shedding_L1', score: 1.0, mwStart: 6.4 }, | |
| ]; | |
| const actionScores = { | |
| load_shedding: { scores: { load_shedding_L1: 1.0 }, params: {} }, | |
| }; | |
| const actions: Record<string, ActionDetail> = { | |
| load_shedding_L1: { | |
| description_unitaire: 'shed L1', | |
| rho_before: null, | |
| rho_after: null, | |
| max_rho: 0.5, | |
| max_rho_line: 'LINE_A', | |
| is_rho_reduction: true, | |
| load_shedding_details: [ | |
| { load_name: 'L1', voltage_level_id: 'VL1', shedded_mw: 5.4 }, | |
| ], | |
| }, | |
| }; | |
| render( | |
| <ActionSearchDropdown | |
| {...defaultProps} | |
| scoredActionsList={scoredActionsList} | |
| actionScores={actionScores} | |
| actions={actions} | |
| cardEditMw={{ load_shedding_L1: '4.2' }} | |
| onCardEditMwChange={onCardEditMwChange} | |
| />, | |
| ); | |
| const input = screen.getByTestId('target-mw-load_shedding_L1') as HTMLInputElement; | |
| // cardEditMw overrides the stored shedded_mw default. | |
| expect(input.value).toBe('4.2'); | |
| }); | |
| // Typing in the score-table row must propagate the change through | |
| // onCardEditMwChange (the shared edit state used by both the row and | |
| // the action card). | |
| it('forwards score-table edits through onCardEditMwChange', () => { | |
| const onCardEditMwChange = vi.fn(); | |
| const scoredActionsList = [ | |
| { type: 'load_shedding', actionId: 'load_shedding_L1', score: 1.0, mwStart: 6.4 }, | |
| ]; | |
| const actionScores = { | |
| load_shedding: { scores: { load_shedding_L1: 1.0 }, params: {} }, | |
| }; | |
| const actions: Record<string, ActionDetail> = { | |
| load_shedding_L1: { | |
| description_unitaire: 'shed L1', | |
| rho_before: null, | |
| rho_after: null, | |
| max_rho: 0.5, | |
| max_rho_line: 'LINE_A', | |
| is_rho_reduction: true, | |
| load_shedding_details: [ | |
| { load_name: 'L1', voltage_level_id: 'VL1', shedded_mw: 5.4 }, | |
| ], | |
| }, | |
| }; | |
| render( | |
| <ActionSearchDropdown | |
| {...defaultProps} | |
| scoredActionsList={scoredActionsList} | |
| actionScores={actionScores} | |
| actions={actions} | |
| onCardEditMwChange={onCardEditMwChange} | |
| />, | |
| ); | |
| const input = screen.getByTestId('target-mw-load_shedding_L1'); | |
| fireEvent.change(input, { target: { value: '4.0' } }); | |
| expect(onCardEditMwChange).toHaveBeenCalledWith('load_shedding_L1', '4.0'); | |
| }); | |
| // A non-computed LS row (no action detail yet) should render an | |
| // empty input — the stored-MW fallback only applies once the action | |
| // has been simulated. | |
| it('leaves LS input empty for non-computed actions', () => { | |
| const scoredActionsList = [ | |
| { type: 'load_shedding', actionId: 'load_shedding_L2', score: 1.0, mwStart: 6.4 }, | |
| ]; | |
| const actionScores = { | |
| load_shedding: { scores: { load_shedding_L2: 1.0 }, params: {} }, | |
| }; | |
| render( | |
| <ActionSearchDropdown | |
| {...defaultProps} | |
| scoredActionsList={scoredActionsList} | |
| actionScores={actionScores} | |
| actions={{}} | |
| />, | |
| ); | |
| const input = screen.getByTestId('target-mw-load_shedding_L2') as HTMLInputElement; | |
| expect(input.value).toBe(''); | |
| }); | |
| }); | |
| // Redispatch actions edit a SIGNED delta (raise > 0 / lower < 0), not an | |
| // absolute target. They get a dedicated "Δ MW" column in the score table, | |
| // mirroring the editable increment already shown on the action card. | |
| describe('Redispatch delta column', () => { | |
| const redispatchScores = { | |
| redispatch: { scores: { redispatch_G1: 1.0 }, params: {} }, | |
| }; | |
| const scoredRedispatch = [ | |
| { type: 'redispatch', actionId: 'redispatch_G1', score: 1.0, mwStart: 14.3 }, | |
| ]; | |
| const computedRedispatch: Record<string, ActionDetail> = { | |
| redispatch_G1: { | |
| description_unitaire: 'redispatch G1', | |
| rho_before: null, | |
| rho_after: null, | |
| max_rho: 0.8, | |
| max_rho_line: 'LINE_A', | |
| 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 }, | |
| ], | |
| }, | |
| }; | |
| it('renders the Δ MW header and an editable delta input for redispatch rows', () => { | |
| render( | |
| <ActionSearchDropdown | |
| {...defaultProps} | |
| scoredActionsList={scoredRedispatch} | |
| actionScores={redispatchScores} | |
| actions={{}} | |
| />, | |
| ); | |
| expect(screen.getByText('Δ MW')).toBeInTheDocument(); | |
| expect(screen.getByTestId('delta-mw-redispatch_G1')).toBeInTheDocument(); | |
| }); | |
| it('populates the delta input with the simulated delta_mw for computed actions', () => { | |
| render( | |
| <ActionSearchDropdown | |
| {...defaultProps} | |
| scoredActionsList={scoredRedispatch} | |
| actionScores={redispatchScores} | |
| actions={computedRedispatch} | |
| />, | |
| ); | |
| const input = screen.getByTestId('delta-mw-redispatch_G1') as HTMLInputElement; | |
| expect(input.value).toBe('10.0'); | |
| }); | |
| it('leaves the delta input empty for non-computed redispatch rows', () => { | |
| render( | |
| <ActionSearchDropdown | |
| {...defaultProps} | |
| scoredActionsList={scoredRedispatch} | |
| actionScores={redispatchScores} | |
| actions={{}} | |
| />, | |
| ); | |
| const input = screen.getByTestId('delta-mw-redispatch_G1') as HTMLInputElement; | |
| expect(input.value).toBe(''); | |
| }); | |
| it('forwards delta edits through onCardEditMwChange', () => { | |
| const onCardEditMwChange = vi.fn(); | |
| render( | |
| <ActionSearchDropdown | |
| {...defaultProps} | |
| scoredActionsList={scoredRedispatch} | |
| actionScores={redispatchScores} | |
| actions={{}} | |
| onCardEditMwChange={onCardEditMwChange} | |
| />, | |
| ); | |
| fireEvent.change(screen.getByTestId('delta-mw-redispatch_G1'), { target: { value: '-5' } }); | |
| expect(onCardEditMwChange).toHaveBeenCalledWith('redispatch_G1', '-5'); | |
| }); | |
| it('adds a non-computed redispatch action with the typed delta as target MW', () => { | |
| const onAddAction = vi.fn(); | |
| render( | |
| <ActionSearchDropdown | |
| {...defaultProps} | |
| scoredActionsList={scoredRedispatch} | |
| actionScores={redispatchScores} | |
| actions={{}} | |
| cardEditMw={{ redispatch_G1: '8' }} | |
| onAddAction={onAddAction} | |
| />, | |
| ); | |
| // Click the row (not the input) to add the action. | |
| fireEvent.click(screen.getByTestId('score-redispatch_G1')); | |
| expect(onAddAction).toHaveBeenCalledWith('redispatch_G1', 8, undefined); | |
| }); | |
| it('re-simulates a computed redispatch action when the delta is edited', () => { | |
| const onResimulate = vi.fn(); | |
| render( | |
| <ActionSearchDropdown | |
| {...defaultProps} | |
| scoredActionsList={scoredRedispatch} | |
| actionScores={redispatchScores} | |
| actions={computedRedispatch} | |
| cardEditMw={{ redispatch_G1: '15' }} | |
| onResimulate={onResimulate} | |
| />, | |
| ); | |
| fireEvent.click(screen.getByTestId('score-redispatch_G1')); | |
| expect(onResimulate).toHaveBeenCalledWith('redispatch_G1', 15); | |
| }); | |
| it('accepts a negative delta for a lower-direction redispatch action', () => { | |
| const lowerRedispatch: Record<string, ActionDetail> = { | |
| redispatch_G1: { | |
| description_unitaire: 'redispatch G1 lower', | |
| rho_before: null, | |
| rho_after: null, | |
| max_rho: 0.8, | |
| max_rho_line: 'LINE_A', | |
| is_rho_reduction: true, | |
| redispatch_details: [ | |
| { gen_name: 'G1', voltage_level_id: 'VL1', delta_mw: -5.0, target_mw: 9.3, direction: 'down', current_mw: 14.3, max_raise_mw: 30.0, max_lower_mw: 14.3 }, | |
| ], | |
| }, | |
| }; | |
| render( | |
| <ActionSearchDropdown | |
| {...defaultProps} | |
| scoredActionsList={scoredRedispatch} | |
| actionScores={redispatchScores} | |
| actions={lowerRedispatch} | |
| />, | |
| ); | |
| const input = screen.getByTestId('delta-mw-redispatch_G1') as HTMLInputElement; | |
| expect(input.value).toBe('-5.0'); | |
| }); | |
| it('mirrors cardEditMw value in the delta input (overriding stored delta)', () => { | |
| render( | |
| <ActionSearchDropdown | |
| {...defaultProps} | |
| scoredActionsList={scoredRedispatch} | |
| actionScores={redispatchScores} | |
| actions={computedRedispatch} | |
| cardEditMw={{ redispatch_G1: '-3' }} | |
| />, | |
| ); | |
| const input = screen.getByTestId('delta-mw-redispatch_G1') as HTMLInputElement; | |
| expect(input.value).toBe('-3'); | |
| }); | |
| }); | |
| // Regression: once "Analyze & Suggest" has produced action scores, | |
| // applying a type filter may filter the score list down to zero. | |
| // Previously the dropdown silently fell back to the full network | |
| // action list, which misled the operator into thinking the | |
| // analysis recommended any of those actions. A yellow warning | |
| // banner must now tell them that no scored action matched the | |
| // selected type. The banner is suppressed when the analysis | |
| // hasn't been run (actionScores undefined) or when the 'all' | |
| // filter is active, to avoid false positives. | |
| describe('no-relevant-action warning (after analyze & suggest)', () => { | |
| it('shows the warning when actionScores are non-empty but the type filter hides them all', () => { | |
| const actionScores = { | |
| pst_tap_change: { | |
| scores: { 'pst-A': 5 }, | |
| params: {}, | |
| }, | |
| }; | |
| render( | |
| <ActionSearchDropdown | |
| {...defaultProps} | |
| // Type filter is RECO but the only scored action is a PST. | |
| filters={{ ...DEFAULT_ACTION_OVERVIEW_FILTERS, actionType: 'reco' }} | |
| scoredActionsList={[]} | |
| actionScores={actionScores} | |
| />, | |
| ); | |
| const banner = screen.getByTestId('no-relevant-action-warning'); | |
| expect(banner).toBeInTheDocument(); | |
| expect(banner.textContent).toMatch(/no relevant action detected/i); | |
| }); | |
| it('does NOT show the warning when actionScores is undefined (analysis not yet run)', () => { | |
| render( | |
| <ActionSearchDropdown | |
| {...defaultProps} | |
| filters={{ ...DEFAULT_ACTION_OVERVIEW_FILTERS, actionType: 'reco' }} | |
| scoredActionsList={[]} | |
| actionScores={undefined} | |
| />, | |
| ); | |
| expect(screen.queryByTestId('no-relevant-action-warning')).not.toBeInTheDocument(); | |
| }); | |
| it('does NOT show the warning when actionScores is empty (analysis ran but produced nothing)', () => { | |
| render( | |
| <ActionSearchDropdown | |
| {...defaultProps} | |
| filters={{ ...DEFAULT_ACTION_OVERVIEW_FILTERS, actionType: 'reco' }} | |
| scoredActionsList={[]} | |
| actionScores={{}} | |
| />, | |
| ); | |
| expect(screen.queryByTestId('no-relevant-action-warning')).not.toBeInTheDocument(); | |
| }); | |
| it('does NOT show the warning under the `all` filter (user is not filtering by type)', () => { | |
| const actionScores = { | |
| pst_tap_change: { scores: { 'pst-A': 5 }, params: {} }, | |
| }; | |
| render( | |
| <ActionSearchDropdown | |
| {...defaultProps} | |
| filters={{ ...DEFAULT_ACTION_OVERVIEW_FILTERS, actionType: 'all' }} | |
| scoredActionsList={[]} | |
| actionScores={actionScores} | |
| />, | |
| ); | |
| expect(screen.queryByTestId('no-relevant-action-warning')).not.toBeInTheDocument(); | |
| }); | |
| it('does NOT show the warning when there is at least one scored action after filtering', () => { | |
| const actionScores = { | |
| pst_tap_change: { scores: { 'pst-A': 5 }, params: {} }, | |
| }; | |
| render( | |
| <ActionSearchDropdown | |
| {...defaultProps} | |
| filters={{ ...DEFAULT_ACTION_OVERVIEW_FILTERS, actionType: 'pst' }} | |
| scoredActionsList={[ | |
| { type: 'pst_tap_change', actionId: 'pst-A', score: 5, mwStart: null }, | |
| ]} | |
| actionScores={actionScores} | |
| />, | |
| ); | |
| expect(screen.queryByTestId('no-relevant-action-warning')).not.toBeInTheDocument(); | |
| }); | |
| it('does NOT show the warning while the user is typing in the search box', () => { | |
| const actionScores = { | |
| pst_tap_change: { scores: { 'pst-A': 5 }, params: {} }, | |
| }; | |
| render( | |
| <ActionSearchDropdown | |
| {...defaultProps} | |
| searchQuery="foo" | |
| filters={{ ...DEFAULT_ACTION_OVERVIEW_FILTERS, actionType: 'reco' }} | |
| scoredActionsList={[]} | |
| actionScores={actionScores} | |
| />, | |
| ); | |
| expect(screen.queryByTestId('no-relevant-action-warning')).not.toBeInTheDocument(); | |
| }); | |
| }); | |
| // Operator-requested addition: a "Simulated Max ρ" column in the | |
| // score table so the user can compare action effectiveness from | |
| // inside the manual-selection modal without bouncing back to the | |
| // action card stack. Pending rows render an em-dash; simulated | |
| // rows show the max-ρ percentage with the same green / orange / | |
| // red severity colouring the ActionCard uses, and divergent / | |
| // islanded simulations render the matching warning label. | |
| describe('Simulated Max ρ column', () => { | |
| const baseScored = [ | |
| { type: 'line_reconnection', actionId: 'reco_1', score: 5, mwStart: null }, | |
| ]; | |
| const baseScores = { | |
| line_reconnection: { scores: { reco_1: 5 }, params: {} }, | |
| }; | |
| it('renders the column header in the score table', () => { | |
| render( | |
| <ActionSearchDropdown | |
| {...defaultProps} | |
| scoredActionsList={baseScored} | |
| actionScores={baseScores} | |
| />, | |
| ); | |
| expect(screen.getByText(/Simulated Max/i)).toBeInTheDocument(); | |
| }); | |
| it('renders an em-dash for an unsimulated row', () => { | |
| render( | |
| <ActionSearchDropdown | |
| {...defaultProps} | |
| scoredActionsList={baseScored} | |
| actionScores={baseScores} | |
| />, | |
| ); | |
| const cell = screen.getByTestId('sim-max-rho-reco_1'); | |
| expect(cell.getAttribute('data-state')).toBe('pending'); | |
| expect(cell.textContent).toBe('—'); | |
| }); | |
| it('renders the green-severity max ρ once the action has been simulated below the monitoring band', () => { | |
| const actions: Record<string, ActionDetail> = { | |
| reco_1: { | |
| description_unitaire: 'r1', | |
| rho_before: [1.05], | |
| rho_after: [0.5], | |
| max_rho: 0.5, | |
| max_rho_line: 'LINE_A', | |
| is_rho_reduction: true, | |
| }, | |
| }; | |
| render( | |
| <ActionSearchDropdown | |
| {...defaultProps} | |
| scoredActionsList={baseScored} | |
| actionScores={baseScores} | |
| actions={actions} | |
| />, | |
| ); | |
| const cell = screen.getByTestId('sim-max-rho-reco_1'); | |
| expect(cell.getAttribute('data-state')).toBe('green'); | |
| expect(cell.textContent).toBe('50.0%'); | |
| expect(cell.getAttribute('title')).toBe('Max ρ on LINE_A'); | |
| }); | |
| it('renders orange severity when max_rho is in the (mf - 0.05, mf] band', () => { | |
| const actions: Record<string, ActionDetail> = { | |
| reco_1: { | |
| description_unitaire: 'r1', | |
| rho_before: [1.05], | |
| rho_after: [0.92], | |
| // monitoringFactor = 0.95 (default). 0.92 is in | |
| // (mf - 0.05, mf] → orange. | |
| max_rho: 0.92, | |
| max_rho_line: 'LINE_A', | |
| is_rho_reduction: true, | |
| }, | |
| }; | |
| render( | |
| <ActionSearchDropdown | |
| {...defaultProps} | |
| scoredActionsList={baseScored} | |
| actionScores={baseScores} | |
| actions={actions} | |
| />, | |
| ); | |
| expect(screen.getByTestId('sim-max-rho-reco_1').getAttribute('data-state')).toBe('orange'); | |
| }); | |
| it('renders red severity when max_rho is above the monitoring factor', () => { | |
| const actions: Record<string, ActionDetail> = { | |
| reco_1: { | |
| description_unitaire: 'r1', | |
| rho_before: [1.05], | |
| rho_after: [1.02], | |
| max_rho: 1.02, | |
| max_rho_line: 'LINE_A', | |
| is_rho_reduction: false, | |
| }, | |
| }; | |
| render( | |
| <ActionSearchDropdown | |
| {...defaultProps} | |
| scoredActionsList={baseScored} | |
| actionScores={baseScores} | |
| actions={actions} | |
| />, | |
| ); | |
| const cell = screen.getByTestId('sim-max-rho-reco_1'); | |
| expect(cell.getAttribute('data-state')).toBe('red'); | |
| expect(cell.textContent).toBe('102.0%'); | |
| }); | |
| it('renders a "divergent" label for a non-convergent simulation', () => { | |
| const actions: Record<string, ActionDetail> = { | |
| reco_1: { | |
| description_unitaire: 'r1', | |
| rho_before: null, | |
| rho_after: null, | |
| // Backend writes max_rho = 0 on non-convergence — the | |
| // numeric value must NOT leak as "0.0%". The | |
| // non_convergence flag wins. | |
| max_rho: 0, | |
| max_rho_line: 'N/A', | |
| is_rho_reduction: false, | |
| non_convergence: 'LoadFlow failure: foo', | |
| }, | |
| }; | |
| render( | |
| <ActionSearchDropdown | |
| {...defaultProps} | |
| scoredActionsList={baseScored} | |
| actionScores={baseScores} | |
| actions={actions} | |
| />, | |
| ); | |
| const cell = screen.getByTestId('sim-max-rho-reco_1'); | |
| expect(cell.getAttribute('data-state')).toBe('divergent'); | |
| expect(cell.textContent).toBe('divergent'); | |
| expect(cell.getAttribute('title')).toContain('LoadFlow failure'); | |
| }); | |
| it('renders an "islanded" label for an islanded simulation', () => { | |
| const actions: Record<string, ActionDetail> = { | |
| reco_1: { | |
| description_unitaire: 'r1', | |
| rho_before: null, | |
| rho_after: null, | |
| max_rho: 0.4, | |
| max_rho_line: 'LINE_A', | |
| is_rho_reduction: false, | |
| is_islanded: true, | |
| disconnected_mw: 42.5, | |
| }, | |
| }; | |
| render( | |
| <ActionSearchDropdown | |
| {...defaultProps} | |
| scoredActionsList={baseScored} | |
| actionScores={baseScores} | |
| actions={actions} | |
| />, | |
| ); | |
| const cell = screen.getByTestId('sim-max-rho-reco_1'); | |
| expect(cell.getAttribute('data-state')).toBe('islanded'); | |
| expect(cell.textContent).toBe('islanded'); | |
| expect(cell.getAttribute('title')).toContain('42.5 MW'); | |
| }); | |
| }); | |
| // Mirrors the ``Simulated Line`` column in | |
| // ``ComputedPairsTable`` — surfaces the branch carrying max ρ on | |
| // the post-action observation, resolved through ``displayName`` | |
| // so the operator sees the friendly pypowsybl substation pair | |
| // (e.g. ``BEON L31CPVAN``) instead of the raw element ID. Pending | |
| // rows and faulted simulations (divergent / islanded) render an | |
| // em-dash because the post-action max-ρ branch is not meaningful | |
| // in those cases. | |
| describe('Simulated Line column', () => { | |
| const baseScored = [ | |
| { type: 'line_reconnection', actionId: 'reco_1', score: 5, mwStart: null }, | |
| ]; | |
| const baseScores = { | |
| line_reconnection: { scores: { reco_1: 5 }, params: {} }, | |
| }; | |
| it('renders the column header in the score table', () => { | |
| render( | |
| <ActionSearchDropdown | |
| {...defaultProps} | |
| scoredActionsList={baseScored} | |
| actionScores={baseScores} | |
| />, | |
| ); | |
| expect(screen.getByText('Simulated Line')).toBeInTheDocument(); | |
| }); | |
| it('renders an em-dash for an unsimulated row', () => { | |
| render( | |
| <ActionSearchDropdown | |
| {...defaultProps} | |
| scoredActionsList={baseScored} | |
| actionScores={baseScores} | |
| />, | |
| ); | |
| const cell = screen.getByTestId('sim-line-reco_1'); | |
| expect(cell.getAttribute('data-state')).toBe('pending'); | |
| expect(cell.textContent).toBe('—'); | |
| }); | |
| it('renders displayName(max_rho_line) once the action has been simulated', () => { | |
| const actions: Record<string, ActionDetail> = { | |
| reco_1: { | |
| description_unitaire: 'r1', | |
| rho_before: [1.05], | |
| rho_after: [0.85], | |
| max_rho: 0.85, | |
| max_rho_line: 'BRANCH_ID_42', | |
| is_rho_reduction: true, | |
| }, | |
| }; | |
| const displayName = (id: string) => id === 'BRANCH_ID_42' ? 'BEON L31CPVAN' : id; | |
| render( | |
| <ActionSearchDropdown | |
| {...defaultProps} | |
| scoredActionsList={baseScored} | |
| actionScores={baseScores} | |
| actions={actions} | |
| displayName={displayName} | |
| />, | |
| ); | |
| const cell = screen.getByTestId('sim-line-reco_1'); | |
| expect(cell.getAttribute('data-state')).toBe('resolved'); | |
| expect(cell.textContent).toBe('BEON L31CPVAN'); | |
| // Raw ID still on the title so the operator can copy it | |
| // if they need to debug at the data layer. | |
| expect(cell.getAttribute('title')).toBe('BRANCH_ID_42'); | |
| }); | |
| it('falls back to the raw id when displayName has no mapping', () => { | |
| const actions: Record<string, ActionDetail> = { | |
| reco_1: { | |
| description_unitaire: 'r1', | |
| rho_before: [1.05], | |
| rho_after: [0.85], | |
| max_rho: 0.85, | |
| max_rho_line: 'UNMAPPED_LINE', | |
| is_rho_reduction: true, | |
| }, | |
| }; | |
| render( | |
| <ActionSearchDropdown | |
| {...defaultProps} | |
| scoredActionsList={baseScored} | |
| actionScores={baseScores} | |
| actions={actions} | |
| />, | |
| ); | |
| expect(screen.getByTestId('sim-line-reco_1').textContent).toBe('UNMAPPED_LINE'); | |
| }); | |
| it('renders an em-dash for a divergent simulation', () => { | |
| const actions: Record<string, ActionDetail> = { | |
| reco_1: { | |
| description_unitaire: 'r1', | |
| rho_before: null, | |
| rho_after: null, | |
| max_rho: 0, | |
| max_rho_line: 'N/A', | |
| is_rho_reduction: false, | |
| non_convergence: 'LoadFlow failure', | |
| }, | |
| }; | |
| render( | |
| <ActionSearchDropdown | |
| {...defaultProps} | |
| scoredActionsList={baseScored} | |
| actionScores={baseScores} | |
| actions={actions} | |
| />, | |
| ); | |
| const cell = screen.getByTestId('sim-line-reco_1'); | |
| expect(cell.getAttribute('data-state')).toBe('unavailable'); | |
| expect(cell.textContent).toBe('—'); | |
| }); | |
| it('renders an em-dash for an islanded simulation', () => { | |
| const actions: Record<string, ActionDetail> = { | |
| reco_1: { | |
| description_unitaire: 'r1', | |
| rho_before: null, | |
| rho_after: null, | |
| max_rho: 0.4, | |
| max_rho_line: 'LINE_A', | |
| is_rho_reduction: false, | |
| is_islanded: true, | |
| disconnected_mw: 12, | |
| }, | |
| }; | |
| render( | |
| <ActionSearchDropdown | |
| {...defaultProps} | |
| scoredActionsList={baseScored} | |
| actionScores={baseScores} | |
| actions={actions} | |
| />, | |
| ); | |
| const cell = screen.getByTestId('sim-line-reco_1'); | |
| expect(cell.getAttribute('data-state')).toBe('unavailable'); | |
| expect(cell.textContent).toBe('—'); | |
| }); | |
| }); | |
| // Score column moved to position 2 (right after the Action | |
| // name) so the ranking is visible without the operator's eye | |
| // having to scan past the per-row MW / tap inputs. Pin both the | |
| // header order AND the row cell order so a future drag-resize | |
| // or re-shuffle can't silently regress it. | |
| describe('Score column position', () => { | |
| const baseScored = [ | |
| { type: 'line_reconnection', actionId: 'reco_1', score: 7.25, mwStart: null }, | |
| ]; | |
| const baseScores = { | |
| line_reconnection: { scores: { reco_1: 7.25 }, params: {} }, | |
| }; | |
| it('renders the Score header in the second column (right after Action)', () => { | |
| render( | |
| <ActionSearchDropdown | |
| {...defaultProps} | |
| scoredActionsList={baseScored} | |
| actionScores={baseScores} | |
| />, | |
| ); | |
| const headers = screen.getAllByRole('columnheader'); | |
| expect(headers[0].textContent).toBe('Action'); | |
| expect(headers[1].textContent).toBe('Score'); | |
| }); | |
| it('renders the score value cell in the second column of the data row', () => { | |
| render( | |
| <ActionSearchDropdown | |
| {...defaultProps} | |
| scoredActionsList={baseScored} | |
| actionScores={baseScores} | |
| />, | |
| ); | |
| const actionRow = screen.getAllByRole('row').find(r => r.textContent?.includes('reco_1')); | |
| const cells = actionRow!.querySelectorAll('td'); | |
| // Column 0 = action name; column 1 = score (newly moved). | |
| expect(cells[0].textContent).toContain('reco_1'); | |
| expect(cells[1].textContent).toBe('7.25'); | |
| expect(screen.getByTestId('score-reco_1').textContent).toBe('7.25'); | |
| }); | |
| }); | |
| // Operator-requested addition: the wide modal now stays mounted | |
| // across multiple simulations (see | |
| // ``ActionFeed.handleAddAction``), so it needs an explicit | |
| // dismiss affordance. Matches the Combine Actions modal close | |
| // button (``CombinedActionsModal.tsx:391``). The narrow inline | |
| // dropdown does not get a header — it relies on outside-click | |
| // dismissal as before. | |
| describe('Close button (wide mode)', () => { | |
| it('renders the close button only when wide AND onClose is wired', () => { | |
| const onClose = vi.fn(); | |
| render(<ActionSearchDropdown {...defaultProps} wide onClose={onClose} />); | |
| expect(screen.getByTestId('manual-selection-close')).toBeInTheDocument(); | |
| }); | |
| it('does not render the close button in narrow mode', () => { | |
| const onClose = vi.fn(); | |
| render(<ActionSearchDropdown {...defaultProps} onClose={onClose} />); | |
| expect(screen.queryByTestId('manual-selection-close')).not.toBeInTheDocument(); | |
| }); | |
| it('does not render the close button when onClose is omitted', () => { | |
| render(<ActionSearchDropdown {...defaultProps} wide />); | |
| expect(screen.queryByTestId('manual-selection-close')).not.toBeInTheDocument(); | |
| }); | |
| it('invokes onClose when the close button is clicked', () => { | |
| const onClose = vi.fn(); | |
| render(<ActionSearchDropdown {...defaultProps} wide onClose={onClose} />); | |
| fireEvent.click(screen.getByTestId('manual-selection-close')); | |
| expect(onClose).toHaveBeenCalledTimes(1); | |
| }); | |
| it('uses the multiplication-sign glyph (×) so it matches the Combine modal', () => { | |
| const onClose = vi.fn(); | |
| render(<ActionSearchDropdown {...defaultProps} wide onClose={onClose} />); | |
| // × → × | |
| expect(screen.getByTestId('manual-selection-close').textContent).toBe('×'); | |
| }); | |
| }); | |
| }); | |