Co-Study4Grid / frontend /src /components /ActionFeed.test.tsx
github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
153 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, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom/vitest';
import { interactionLogger } from '../utils/interactionLogger';
// Mocking dependencies
vi.mock('../api', () => ({
api: {
getAvailableActions: vi.fn(async () => []),
simulateManualAction: vi.fn(),
simulateAndVariantDiagramStream: vi.fn(),
getNetworkDiagram: vi.fn(),
computeSuperposition: vi.fn(),
}
}));
vi.mock('../utils/svgUtils', () => ({
getActionTargetVoltageLevels: vi.fn(() => []),
getActionTargetLines: vi.fn(() => []),
processSvg: vi.fn((svg: string) => ({ svg, viewBox: { x: 0, y: 0, w: 100, h: 100 } })),
buildMetadataIndex: vi.fn(),
applyOverloadedHighlights: vi.fn(),
applyDeltaVisuals: vi.fn(),
applyActionTargetHighlights: vi.fn(),
applyContingencyHighlight: vi.fn(),
isCouplingAction: vi.fn(() => false),
// Always passing the severity/threshold gate isolates the
// action-type filter tests from the category/threshold filter.
actionPassesOverviewFilter: vi.fn(() => true),
}));
import ActionFeed from './ActionFeed';
import { api } from '../api';
import { getActionTargetVoltageLevels, getActionTargetLines } from '../utils/svgUtils';
import type { ActionDetail, AnalysisResult, CombinedAction } from '../types';
describe('ActionFeed', () => {
const emptyTopo = { lines_ex_bus: {}, lines_or_bus: {}, gens_bus: {}, loads_bus: {} };
const defaultProps = {
actions: {} as Record<string, ActionDetail>,
actionScores: {} as Record<string, Record<string, unknown>>,
linesOverloaded: ['LINE_1'] as string[],
selectedActionId: null,
selectedActionIds: new Set<string>(),
rejectedActionIds: new Set<string>(),
onActionSelect: vi.fn(),
onActionFavorite: vi.fn(),
onActionReject: vi.fn(),
onAssetClick: vi.fn(),
onDisplayPrioritizedActions: vi.fn(),
onRunAnalysis: vi.fn(),
canRunAnalysis: false,
nodesByEquipmentId: new Map(),
edgesByEquipmentId: new Map(),
disconnectedElement: 'LINE_1',
onManualActionAdded: vi.fn(),
onActionResimulated: vi.fn(),
analysisLoading: false,
monitoringFactor: 0.95,
manuallyAddedIds: new Set<string>(),
pendingAnalysisResult: null as AnalysisResult | null,
combinedActions: null as Record<string, CombinedAction> | null,
};
it('shows "Make a first guess" button when the Selected Actions section is empty', () => {
// Regression guard: the standalone interface exposes a
// "💡 Make a first guess" shortcut that opens the Manual
// Selection search. The React frontend was missing it, so
// users had no obvious entry point from the empty Selected
// Actions slot into manual action exploration.
render(<ActionFeed {...defaultProps} />);
expect(screen.getByTestId('make-first-guess-button')).toBeInTheDocument();
expect(screen.getByText(/Make a first guess/)).toBeInTheDocument();
});
it('"Make a first guess" opens the manual search dropdown', () => {
render(<ActionFeed {...defaultProps} />);
// The dropdown search input is not present until the user
// opens the search.
expect(screen.queryByPlaceholderText(/Search action by ID/)).not.toBeInTheDocument();
fireEvent.click(screen.getByTestId('make-first-guess-button'));
expect(screen.getByPlaceholderText(/Search action by ID/)).toBeInTheDocument();
});
describe('"Make a first guess" gating after Analyze & Suggest', () => {
// Product rule: once the user has triggered Analyze & Suggest
// (or it's currently running, or its result is pending
// display), the pre-analysis "Make a first guess" shortcut is
// suppressed. It should reappear only after a state reset
// (contingency change, study reload) clears actionScores /
// pendingAnalysisResult / actions.
it('hides the button while analysis is in progress', () => {
render(<ActionFeed {...defaultProps} analysisLoading={true} />);
expect(screen.queryByTestId('make-first-guess-button')).not.toBeInTheDocument();
});
it('shows a Cancel control while analyzing and invokes onCancelAnalysis (D5)', () => {
const onCancelAnalysis = vi.fn();
render(<ActionFeed {...defaultProps} analysisLoading={true} onCancelAnalysis={onCancelAnalysis} />);
const cancel = screen.getByTestId('cancel-analysis');
expect(cancel).toBeInTheDocument();
fireEvent.click(cancel);
expect(onCancelAnalysis).toHaveBeenCalledTimes(1);
});
it('omits the Cancel control when no onCancelAnalysis handler is provided', () => {
render(<ActionFeed {...defaultProps} analysisLoading={true} />);
expect(screen.queryByTestId('cancel-analysis')).not.toBeInTheDocument();
});
it('hides the button while a pending analysis result is awaiting display', () => {
const pending = {
actions: {},
lines_overloaded: [],
action_scores: {},
} as unknown as AnalysisResult;
render(<ActionFeed {...defaultProps} pendingAnalysisResult={pending} />);
expect(screen.queryByTestId('make-first-guess-button')).not.toBeInTheDocument();
});
it('hides the button when action scores are present (analysis completed)', () => {
const actionScores = {
line_disconnection: { scores: { 'disco_A': 1.0 }, params: {} },
};
render(<ActionFeed {...defaultProps} actionScores={actionScores} />);
expect(screen.queryByTestId('make-first-guess-button')).not.toBeInTheDocument();
});
it('hides the button when the actions dict is non-empty (at least one simulated action)', () => {
const actions = {
manually_added: {
description_unitaire: 'Manual',
action_topology: emptyTopo,
is_manual: true,
} as unknown as ActionDetail,
};
render(<ActionFeed {...defaultProps} actions={actions} />);
expect(screen.queryByTestId('make-first-guess-button')).not.toBeInTheDocument();
});
it('still shows the button when actionScores is an empty object (analysis never ran)', () => {
// The defaultProps already use `actionScores: {}` which is
// the initial "analysis has not produced anything yet"
// state — a truthy but empty object. The button must still
// appear.
render(<ActionFeed {...defaultProps} actionScores={{}} />);
expect(screen.getByTestId('make-first-guess-button')).toBeInTheDocument();
});
it('re-appears after a simulated reset (all analysis-related state cleared)', () => {
const { rerender } = render(
<ActionFeed
{...defaultProps}
actionScores={{ line_disconnection: { scores: { 'disco_A': 1.0 }, params: {} } }}
/>,
);
expect(screen.queryByTestId('make-first-guess-button')).not.toBeInTheDocument();
// Simulate state reset: parent clears result/actionScores
// (emulating resetAllState on contingency/study change).
rerender(
<ActionFeed
{...defaultProps}
actionScores={{}}
pendingAnalysisResult={null}
analysisLoading={false}
actions={{}}
/>,
);
expect(screen.getByTestId('make-first-guess-button')).toBeInTheDocument();
});
});
it('keeps a manually added action in Selected and shows the overlap warning when it is ALSO suggested by the analysis', () => {
// Regression: a user "first guess" that coincides with a
// recommender suggestion used to silently vanish from the
// Selected bucket after analysis. It must stay selected and
// the yellow "also recommended" warning must fire.
const actionId = 'overlap_act';
const props = {
...defaultProps,
actions: {
[actionId]: {
description_unitaire: 'Shared action',
rho_before: [1.1],
rho_after: [0.8],
max_rho: 0.8,
max_rho_line: 'LINE_A',
is_rho_reduction: true,
is_manual: true,
action_topology: emptyTopo,
},
},
// The same id is present in both the selected set (user
// added it manually) and the analysis scores (the
// recommender also recommends it).
selectedActionIds: new Set([actionId]),
manuallyAddedIds: new Set([actionId]),
actionScores: {
line_disconnection: {
scores: { [actionId]: 42 },
params: {},
},
},
};
render(<ActionFeed {...props} />);
// Selected Actions section still shows the card. (The
// description is now progressive-disclosure, so we assert on
// the card root by testId rather than its description text.)
expect(screen.getByTestId(`action-card-${actionId}`)).toBeInTheDocument();
// Overlap is now an inline contextual hint (tier-warning-system PR — see
// `docs/proposals/ui-design-critique.md` recommendation #4).
const hint = screen.getByTestId('selected-overlap-hint');
expect(hint).toBeInTheDocument();
expect(hint.textContent).toMatch(/also recommended by the recent analysis/i);
expect(hint.textContent).toContain(actionId);
});
it('hides "Make a first guess" when there are already selected actions', () => {
const actionId = 'manual_1';
const props = {
...defaultProps,
actions: {
[actionId]: {
description_unitaire: 'Manual Action',
rho_before: [1.0],
rho_after: [0.8],
max_rho: 0.8,
max_rho_line: 'LINE_A',
is_rho_reduction: true,
is_manual: true,
action_topology: emptyTopo,
},
},
selectedActionIds: new Set([actionId]),
};
render(<ActionFeed {...props} />);
expect(screen.queryByTestId('make-first-guess-button')).not.toBeInTheDocument();
});
it('renders "Scored Actions" heading when search is opened and actions are present', async () => {
const actionId = 'act_1';
const props = {
...defaultProps,
actionScores: {
line_reconnection: {
scores: { [actionId]: 10 },
params: {},
}
},
};
render(<ActionFeed {...props} />);
fireEvent.click(screen.getByText('+ Manual Selection'));
expect(await screen.findByText('Scored Actions')).toBeInTheDocument();
});
it('hides prioritized suggestions while analysis is loading', () => {
const actionId = 'suggested_1';
const props = {
...defaultProps,
actions: {
[actionId]: {
description_unitaire: 'Suggested Action',
rho_before: [1.0],
rho_after: [0.8],
max_rho: 0.8,
max_rho_line: 'LINE_A',
is_rho_reduction: true,
is_manual: false,
action_topology: emptyTopo
}
},
analysisLoading: true,
};
render(<ActionFeed {...props} />);
expect(screen.queryByText('Suggested Action')).not.toBeInTheDocument();
// Processing indicator is now visible during analysis
expect(screen.getByText('⚙️ Analyzing…')).toBeInTheDocument();
});
it('shows manual actions while analysis is loading', () => {
const actionId = 'manual_1';
const props = {
...defaultProps,
actions: {
[actionId]: {
description_unitaire: 'Manual Action',
rho_before: [1.0],
rho_after: [0.8],
max_rho: 0.8,
max_rho_line: 'LINE_A',
is_rho_reduction: true,
is_manual: true,
action_topology: emptyTopo
}
},
selectedActionIds: new Set([actionId]),
analysisLoading: true,
};
render(<ActionFeed {...props} />);
// Card is rendered (description is progressive-disclosure).
expect(screen.getByTestId(`action-card-${actionId}`)).toBeInTheDocument();
// Processing indicator is visible even when viewing selected actions
expect(screen.getByText('⚙️ Analyzing…')).toBeInTheDocument();
});
it('shows display prioritized actions button when pending results exist and loading is false', () => {
const props = {
...defaultProps,
analysisLoading: false,
pendingAnalysisResult: {
actions: { 'new_act': { description_unitaire: 'New', rho_before: [], rho_after: [], max_rho: 0.5, max_rho_line: '', is_rho_reduction: true, action_topology: emptyTopo } },
lines_overloaded: [],
topo_info: { lines_ex_bus: {}, lines_or_bus: {}, gens_bus: {}, loads_bus: {} },
pdf_path: null,
pdf_url: null,
message: 'done',
dc_fallback: false
} as AnalysisResult,
};
render(<ActionFeed {...props} />);
expect(screen.getByText(/Display 1 prioritized actions/)).toBeInTheDocument();
});
it('calls onDisplayPrioritizedActions when display button is clicked', () => {
const onDisplay = vi.fn();
const props = {
...defaultProps,
analysisLoading: false,
onDisplayPrioritizedActions: onDisplay,
pendingAnalysisResult: {
actions: { 'new_act': { description_unitaire: 'New', rho_before: [], rho_after: [], max_rho: 0.5, max_rho_line: '', is_rho_reduction: true, action_topology: emptyTopo } },
lines_overloaded: [],
topo_info: { lines_ex_bus: {}, lines_or_bus: {}, gens_bus: {}, loads_bus: {} },
pdf_path: null,
pdf_url: null,
message: 'done',
dc_fallback: false
} as AnalysisResult,
};
render(<ActionFeed {...props} />);
fireEvent.click(screen.getByText(/Display 1 prioritized actions/));
expect(onDisplay).toHaveBeenCalled();
});
it('shows "computed" tag for actions without non_convergence in search table', async () => {
const actionId = 'act_1';
const props = {
...defaultProps,
actions: {
[actionId]: {
description_unitaire: 'Test',
rho_before: [1.0],
rho_after: [0.8],
max_rho: 0.8,
max_rho_line: 'LINE_A',
is_rho_reduction: true,
action_topology: emptyTopo
}
},
actionScores: {
line_reconnection: {
scores: { [actionId]: 10 },
params: {},
}
}
};
render(<ActionFeed {...props} />);
fireEvent.click(screen.getByText('+ Manual Selection'));
expect(await screen.findByTestId(`badge-computed-${actionId}`)).toBeInTheDocument();
expect(screen.queryByTestId(`badge-divergent-${actionId}`)).not.toBeInTheDocument();
});
it('shows "divergent" tag for non-convergent actions in search table', async () => {
const actionId = 'act_2';
const props = {
...defaultProps,
actions: {
[actionId]: {
description_unitaire: 'Test',
rho_before: [1.0],
rho_after: null,
max_rho: null,
max_rho_line: 'N/A',
is_rho_reduction: false,
non_convergence: 'LF error',
action_topology: emptyTopo
}
},
actionScores: {
line_reconnection: {
scores: { [actionId]: 5 },
params: {},
}
}
};
render(<ActionFeed {...props} />);
fireEvent.click(screen.getByText('+ Manual Selection'));
expect(await screen.findByTestId(`badge-divergent-${actionId}`)).toBeInTheDocument();
expect(screen.queryByTestId(`badge-computed-${actionId}`)).not.toBeInTheDocument();
});
it('renders red "divergent" badge and orange warning box for non-convergent action card', () => {
const actionId = 'act_bad';
const props = {
...defaultProps,
actions: {
[actionId]: {
description_unitaire: 'Test Bad Action',
rho_before: [1.0],
rho_after: null,
max_rho: null,
max_rho_line: 'N/A',
is_rho_reduction: false,
non_convergence: 'Critical Error',
action_topology: emptyTopo
}
},
selectedActionIds: new Set([actionId])
};
render(<ActionFeed {...props} />);
const card = screen.getByTestId(`action-card-${actionId}`);
// 1. Check Divergent Badge (danger token) — the severity badge is
// an icon-only pictogram, its wording lives in the `title` tooltip.
const badge = screen.getByTestId(`action-card-${actionId}-severity`);
expect(badge).toHaveAttribute('title', 'divergent');
expect(badge.style.background).toContain('var(--color-danger)');
// 2. Check Warning Box (warning tokens)
const warningBox = screen.getByText(/LoadFlow failure: Critical Error/);
expect(warningBox.style.color).toContain('var(--color-warning-text)');
expect(warningBox.style.backgroundColor).toContain('var(--color-warning-soft)');
// 3. Check Card Background (danger-soft token)
expect(card.style.background).toContain('var(--color-danger-soft)');
});
it('ranks non-convergent actions at the bottom', () => {
const props = {
...defaultProps,
actions: {
act_good: { description_unitaire: 'Good Action', rho_before: [1.0], rho_after: [0.8], max_rho: 0.8, max_rho_line: 'A', is_rho_reduction: true, action_topology: emptyTopo },
act_bad: { description_unitaire: 'Bad Action', rho_before: [1.0], rho_after: null, max_rho: null, max_rho_line: 'N/A', is_rho_reduction: false, non_convergence: 'Error', action_topology: emptyTopo }
},
selectedActionIds: new Set(['act_good', 'act_bad'])
};
render(<ActionFeed {...props} />);
// Cards are referenced by their testIds — the description text
// is hidden at-rest under progressive disclosure, so the
// ordering check has to walk the action-card-${id} test IDs in
// DOM order instead of scanning rendered text.
const cards = screen.getAllByTestId(/^action-card-(act_good|act_bad)$/);
const cardIds = cards.map(el => el.getAttribute('data-testid'));
const goodIndex = cardIds.indexOf('action-card-act_good');
const badIndex = cardIds.indexOf('action-card-act_bad');
expect(goodIndex).toBeLessThan(badIndex);
});
// Backend regression guard: ``compute_action_metrics`` zeroes
// ``max_rho`` on a non-convergent simulation (see
// ``expert_backend/services/simulation_helpers.py`` —
// ``"max_rho": 0.0`` is the default before the convergence
// check). Sorting by ``max_rho`` alone would float the
// divergent card to the top of the stack ahead of legitimate
// solving actions — this test pins ``non_convergence`` as the
// dominant ordering key in that situation.
it('ranks divergent actions (max_rho=0) at the bottom, not the top', () => {
const props = {
...defaultProps,
actions: {
act_solving: { description_unitaire: 'Solving Action', rho_before: [1.0], rho_after: [0.9], max_rho: 0.9, max_rho_line: 'A', is_rho_reduction: true, action_topology: emptyTopo },
act_divergent: { description_unitaire: 'Divergent Action', rho_before: [1.0], rho_after: null, max_rho: 0, max_rho_line: 'N/A', is_rho_reduction: false, non_convergence: 'LoadFlow failure', action_topology: emptyTopo },
},
selectedActionIds: new Set(['act_solving', 'act_divergent']),
};
render(<ActionFeed {...props} />);
const cards = screen.getAllByTestId(/^action-card-(act_solving|act_divergent)$/);
const cardIds = cards.map(el => el.getAttribute('data-testid'));
expect(cardIds.indexOf('action-card-act_solving')).toBeLessThan(
cardIds.indexOf('action-card-act_divergent'),
);
});
it('shows only PST actions in dropdown after clicking the PST chip', async () => {
const pstAction = { id: 'pst_tap_up', description: 'PST action', type: 'pst_tap_change' };
const regularAction = { id: 'line_reco_1', description: 'Regular action', type: 'line_reconnection' };
vi.mocked(api.getAvailableActions).mockResolvedValueOnce([pstAction, regularAction]);
render(<ActionFeed {...defaultProps} />);
fireEvent.click(screen.getByText('+ Manual Selection'));
const pstChip = await screen.findByTestId('sidebar-filter-type-pst');
fireEvent.click(pstChip);
// PST action visible, reco action hidden
expect(await screen.findByText('pst_tap_up')).toBeInTheDocument();
expect(screen.queryByText('line_reco_1')).not.toBeInTheDocument();
});
it('shows all actions in dropdown when ALL chip is active (default)', async () => {
const pstAction = { id: 'pst_tap_up', description: 'PST action', type: 'pst_tap_change' };
const regularAction = { id: 'line_reco_1', description: 'Regular action', type: 'line_reconnection' };
vi.mocked(api.getAvailableActions).mockResolvedValueOnce([pstAction, regularAction]);
render(<ActionFeed {...defaultProps} />);
fireEvent.click(screen.getByText('+ Manual Selection'));
expect(await screen.findByText('pst_tap_up')).toBeInTheDocument();
expect(screen.getByText('line_reco_1')).toBeInTheDocument();
});
it('hides PST actions in search results after setting filter to RECO', async () => {
const pstAction = { id: 'pst_tap_up', description: 'PST action' };
vi.mocked(api.getAvailableActions).mockResolvedValueOnce([pstAction]);
render(<ActionFeed {...defaultProps} />);
fireEvent.click(screen.getByText('+ Manual Selection'));
// Set filter to RECO
const recoChip = await screen.findByTestId('sidebar-filter-type-reco');
fireEvent.click(recoChip);
// Type "pst" in search
const searchInput = screen.getByPlaceholderText(/Search action/);
fireEvent.change(searchInput, { target: { value: 'pst' } });
await waitFor(() => {
expect(screen.queryByText('Loading actions...')).not.toBeInTheDocument();
});
// PST action NOT visible because filter is 'reco'
expect(screen.queryByText('pst_tap_up')).not.toBeInTheDocument();
// Manual simulation option still visible
expect(screen.getByText(/Simulate manual ID:/)).toBeInTheDocument();
});
it('shows only PST actions in dropdown after clicking PST chip (excludes disco and unknown)', async () => {
const pstAction = { id: 'pst_1', description: 'PST action' };
const discoAction = { id: 'abc', description: 'Ouverture de ligne' };
const unknownAction = { id: 'xyz', description: 'Some unknown action' };
vi.mocked(api.getAvailableActions).mockResolvedValueOnce([pstAction, discoAction, unknownAction]);
render(<ActionFeed {...defaultProps} />);
fireEvent.click(screen.getByText('+ Manual Selection'));
const pstChip = await screen.findByTestId('sidebar-filter-type-pst');
fireEvent.click(pstChip);
expect(await screen.findByText('pst_1')).toBeInTheDocument();
expect(screen.queryByText('abc')).not.toBeInTheDocument();
expect(screen.queryByText('xyz')).not.toBeInTheDocument();
});
it('shows only LS actions in dropdown after clicking LS chip', async () => {
const lsAction = { id: 'load_shedding_LOAD1', description: 'Load shedding LOAD1' };
const regularAction = { id: 'line_reco_1', description: 'Reconnexion' };
vi.mocked(api.getAvailableActions).mockResolvedValueOnce([lsAction, regularAction]);
render(<ActionFeed {...defaultProps} />);
fireEvent.click(screen.getByText('+ Manual Selection'));
const lsChip = await screen.findByTestId('sidebar-filter-type-ls');
fireEvent.click(lsChip);
expect(await screen.findByText('load_shedding_LOAD1')).toBeInTheDocument();
expect(screen.queryByText('line_reco_1')).not.toBeInTheDocument();
});
it('triggers manual simulation when "Simulate manual ID" is clicked', async () => {
render(<ActionFeed {...defaultProps} />);
// Open search
fireEvent.click(screen.getByText('+ Manual Selection'));
// Type something that doesn't exist
const searchInput = screen.getByPlaceholderText(/Search action/);
fireEvent.change(searchInput, { target: { value: 'custom_action_123' } });
// Wait for loading to finish
await waitFor(() => {
expect(screen.queryByText('Loading actions...')).not.toBeInTheDocument();
});
// Click "Simulate manual ID"
const manualOption = screen.getByText(/Simulate manual ID:/);
fireEvent.click(manualOption);
// Verify simulateManualAction was called
expect(api.simulateManualAction).toHaveBeenCalledWith(
'custom_action_123',
'LINE_1',
null,
['LINE_1'],
undefined,
undefined,
);
});
// Operator-requested addition: chain several manual simulations
// from the wide score-table modal without it auto-closing each
// round. Mirrors the Combine Actions modal's contract — the
// operator typically wants to compare a handful of candidate
// remedies before committing. The narrow no-score search still
// auto-dismisses (one-shot "type an ID, hit enter" flow).
describe('manual-selection modal lifecycle (multi-simulation flow)', () => {
// ``defaultProps.onManualActionAdded`` (and the api module
// mocks) are shared ``vi.fn()`` references at the describe-block
// scope, so a successful simulation in this group would
// otherwise carry call history into the unrelated
// race-condition guard below ("awaits simulation before
// calling onManualActionAdded …"). Clear before each test in
// this group AND after the group finishes so the surrounding
// suite sees a clean slate.
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
const buildScoredProps = () => ({
...defaultProps,
actionScores: {
line_reconnection: {
scores: { reco_A: 5, reco_B: 4 },
params: {},
},
} as Record<string, Record<string, unknown>>,
actions: {} as Record<string, ActionDetail>,
});
const mockSimulateOk = (actionId: string) => {
vi.mocked(api.simulateManualAction).mockResolvedValueOnce({
action_id: actionId,
description_unitaire: 'simulated',
rho_before: [1.05],
rho_after: [0.85],
max_rho: 0.85,
max_rho_line: 'LINE_1',
is_rho_reduction: true,
is_islanded: false,
n_components: 1,
disconnected_mw: 0,
non_convergence: null,
lines_overloaded: ['LINE_1'],
lines_overloaded_after: [],
load_shedding_details: [],
curtailment_details: [],
pst_details: [],
action_topology: emptyTopo,
is_estimated: false,
} as Awaited<ReturnType<typeof api.simulateManualAction>>);
};
it('keeps the wide score-table modal open after a successful manual simulation', async () => {
const props = buildScoredProps();
render(<ActionFeed {...props} />);
fireEvent.click(screen.getByText('+ Manual Selection'));
// Wide layout active — scored actions visible.
expect(screen.getByTestId('manual-selection-wide')).toBeInTheDocument();
expect(screen.queryByTestId('manual-selection-dropdown')).not.toBeInTheDocument();
// Wait for the available-actions fetch to settle before the
// ScoreTable renders the row links.
await waitFor(() => {
expect(screen.queryByText('Loading actions...')).not.toBeInTheDocument();
});
mockSimulateOk('reco_A');
fireEvent.click(screen.getByText('reco_A'));
await waitFor(() => {
expect(api.simulateManualAction).toHaveBeenCalledTimes(1);
});
// The wide overlay must still be mounted — pre-fix it
// unmounted on every simulation, forcing the operator
// to reopen + re-scroll for the next candidate.
expect(screen.getByTestId('manual-selection-wide')).toBeInTheDocument();
// The score list is preserved so the next row is one
// click away (no need to re-filter / re-search).
expect(screen.getByText('reco_B')).toBeInTheDocument();
});
it('keeps the wide overlay layout when a chip filter empties the scored list', async () => {
// Pre-fix the modal layout was driven by the *post-filter*
// scoredActionsList length: switching to a chip with no
// matching scored action collapsed the wide overlay back to
// the small button-anchored dropdown, which read as the
// modal closing mid-interaction. Once analysis has produced
// any scored action the wide layout must stick across chip
// toggles — empty chips just surface the existing
// "no relevant action detected" warning + raw-action list.
const props = buildScoredProps();
render(<ActionFeed {...props} />);
fireEvent.click(screen.getByText('+ Manual Selection'));
expect(screen.getByTestId('manual-selection-wide')).toBeInTheDocument();
await waitFor(() => {
expect(screen.queryByText('Loading actions...')).not.toBeInTheDocument();
});
// Pick a chip filter that yields zero scored rows
// (line_reconnection is the only scored type in
// ``buildScoredProps``; the PST chip filters everything out).
const pstChip = await screen.findByTestId('sidebar-filter-type-pst');
fireEvent.click(pstChip);
// The wide overlay must remain mounted; the narrow
// dropdown must NOT take its place.
expect(screen.getByTestId('manual-selection-wide')).toBeInTheDocument();
expect(screen.queryByTestId('manual-selection-dropdown')).not.toBeInTheDocument();
});
it('still closes the narrow no-score dropdown after a manual ID is simulated', async () => {
// No actionScores → narrow layout, traditional one-shot
// flow. Closing on success is the expected UX.
render(<ActionFeed {...defaultProps} />);
fireEvent.click(screen.getByText('+ Manual Selection'));
expect(screen.getByTestId('manual-selection-dropdown')).toBeInTheDocument();
const searchInput = screen.getByPlaceholderText(/Search action/);
fireEvent.change(searchInput, { target: { value: 'custom_42' } });
await waitFor(() => {
expect(screen.queryByText('Loading actions...')).not.toBeInTheDocument();
});
mockSimulateOk('custom_42');
fireEvent.click(screen.getByText(/Simulate manual ID:/));
await waitFor(() => {
expect(screen.queryByTestId('manual-selection-dropdown')).not.toBeInTheDocument();
});
});
});
it('hides actions that classify as disco even when their score type is pst, when filter is "pst"', async () => {
// classifyActionType puts this action in 'disco' because the description
// starts with "Ouverture" — it should NOT appear under the PST chip filter.
const pstDiscoAction = {
id: 'disco_pst_branch',
description: 'Ouverture de la branche PST',
type: 'pst_tap_change',
};
vi.mocked(api.getAvailableActions).mockResolvedValueOnce([pstDiscoAction]);
render(<ActionFeed {...defaultProps} />);
fireEvent.click(screen.getByText('+ Manual Selection'));
const pstChip = await screen.findByTestId('sidebar-filter-type-pst');
fireEvent.click(pstChip);
await waitFor(() => {
expect(screen.queryByText('Loading actions...')).not.toBeInTheDocument();
});
expect(screen.queryByText('disco_pst_branch')).not.toBeInTheDocument();
expect(screen.getByText('All actions already added')).toBeInTheDocument();
});
// Action-dictionary stats and recommender thresholds were moved
// out of ActionFeed and into NoticesPanel (tier-warning-system PR — see
// `docs/proposals/ui-design-critique.md` recommendation #4). The
// matching coverage now lives in `NoticesPanel.test.tsx`.
it('shows yellow processing button during analysisLoading', () => {
const props = {
...defaultProps,
analysisLoading: true,
};
render(<ActionFeed {...props} />);
const banner = screen.getByText('⚙️ Analyzing…');
expect(banner).toBeInTheDocument();
expect(banner.style.background).toContain('var(--color-warning-soft)');
expect(banner.style.color).toContain('var(--color-warning-text)');
});
// The recommender-settings hint and the action-dict "Change in
// settings" affordance live in NoticesPanel after tier-warning-system PR. See
// `NoticesPanel.test.tsx` for the moved coverage.
it('awaits simulation before calling onManualActionAdded to prevent race conditions', async () => {
const actionId = 'act1';
const mockResult = {
description_unitaire: 'Description',
rho_before: [1.0],
rho_after: [0.8],
max_rho: 0.8,
max_rho_line: 'LINE_A',
is_rho_reduction: true,
action_id: actionId,
lines_overloaded: []
};
vi.mocked(api.getAvailableActions).mockResolvedValue([{ id: 'act1', type: 'disco', description: 'desc' }]);
// Mock a slow simulation
let simulationFinished = false;
vi.mocked(api.simulateManualAction).mockImplementation(async () => {
await new Promise(resolve => setTimeout(resolve, 50));
simulationFinished = true;
return {
...mockResult,
non_convergence: null,
is_estimated: false,
lines_overloaded: []
} as { action_id: string; description_unitaire: string; rho_before: number[] | null; rho_after: number[] | null; max_rho: number | null; max_rho_line: string; is_rho_reduction: boolean; is_islanded?: boolean; n_components?: number; disconnected_mw?: number; non_convergence: string | null; lines_overloaded: string[]; };
});
const props = {
...defaultProps,
disconnectedElement: 'contingency_A',
};
const { getByText, getByPlaceholderText } = render(<ActionFeed {...props} />);
// Open search and add action
fireEvent.click(getByText('+ Manual Selection'));
// Wait for actions to load
await waitFor(() => expect(screen.queryByText('Loading actions...')).not.toBeInTheDocument());
const input = getByPlaceholderText('Search action by ID or description...');
fireEvent.change(input, { target: { value: 'act1' } });
const actionCard = await screen.findByTestId('action-card-act1');
fireEvent.click(actionCard);
// Verify it shows loading state
expect(screen.getByText('Simulating...')).toBeInTheDocument();
// At this point onManualActionAdded should NOT have been called yet
expect(defaultProps.onManualActionAdded).not.toHaveBeenCalled();
// Wait for simulation to finish
await waitFor(() => expect(simulationFinished).toBe(true));
// Now onManualActionAdded should have been called
await waitFor(() => {
expect(defaultProps.onManualActionAdded).toHaveBeenCalledWith(
actionId,
expect.objectContaining({ max_rho: 0.8 }),
[]
);
});
});
it('filters out combined actions that are marked as is_estimated', () => {
const combinedId = 'act1+act2';
const props = {
...defaultProps,
actions: {
[combinedId]: {
description_unitaire: 'Combined Action',
rho_before: [1.0],
rho_after: [0.8],
max_rho: 0.8,
max_rho_line: 'LINE_A',
is_rho_reduction: true,
is_estimated: true, // This should cause it to be filtered out
action_topology: emptyTopo
}
},
selectedActionIds: new Set([combinedId]),
analysisLoading: false,
};
render(<ActionFeed {...props} />);
expect(screen.queryByText('Combined Action')).not.toBeInTheDocument();
});
it('shows simulated combined actions that are NOT marked as is_estimated', () => {
const combinedId = 'act1+act2';
const props = {
...defaultProps,
actions: {
[combinedId]: {
description_unitaire: 'Simulated Combined Action',
rho_before: [1.0],
rho_after: [0.8],
max_rho: 0.8,
max_rho_line: 'LINE_A',
is_rho_reduction: true,
is_estimated: false,
action_topology: emptyTopo
}
},
selectedActionIds: new Set([combinedId]),
analysisLoading: false,
};
render(<ActionFeed {...props} />);
// Card is rendered (description is progressive-disclosure).
expect(screen.getByTestId(`action-card-${combinedId}`)).toBeInTheDocument();
});
it('renders multiple asset badges for combined actions', () => {
const combinedId = 'VL1+LINE_A';
const props = {
...defaultProps,
actions: {
[combinedId]: {
description_unitaire: "Action on 'VL1' and LINE_A",
rho_before: [1.0],
rho_after: [0.8],
max_rho: 0.8,
max_rho_line: 'LINE_B',
is_rho_reduction: true,
action_topology: emptyTopo
}
},
selectedActionIds: new Set([combinedId]),
nodesByEquipmentId: new Map([['VL1', { equipmentId: 'VL1', svgId: 'svg-vl1', x: 0, y: 0 }]]),
edgesByEquipmentId: new Map([['LINE_A', { equipmentId: 'LINE_A', svgId: 'svg-line-a', node1: 'n1', node2: 'n2' }]]),
};
// Mock utils to return the assets
vi.mocked(getActionTargetVoltageLevels).mockReturnValue(['VL1']);
vi.mocked(getActionTargetLines).mockReturnValue(['LINE_A']);
render(<ActionFeed {...props} />);
// Should see both badges
expect(screen.getByText('VL1')).toBeInTheDocument();
expect(screen.getByText('LINE_A')).toBeInTheDocument();
// Clicking a badge should call onAssetClick
fireEvent.click(screen.getByText('VL1'));
expect(props.onAssetClick).toHaveBeenCalledWith(combinedId, 'VL1', 'action');
fireEvent.click(screen.getByText('LINE_A'));
expect(props.onAssetClick).toHaveBeenCalledWith(combinedId, 'LINE_A', 'action');
});
it('updates severity badges based on monitoringFactor', () => {
const actionId = 'act_test';
const actionDetail = {
description_unitaire: 'Test Action',
max_rho: 0.93,
max_rho_line: 'LINE_A',
rho_before: [1.0],
rho_after: [0.93],
is_rho_reduction: true,
action_topology: emptyTopo
};
const renderWithMF = (mf: number) => render(
<ActionFeed
{...defaultProps}
actions={{ [actionId]: actionDetail }}
selectedActionIds={new Set([actionId])}
monitoringFactor={mf}
/>
);
// 1. MF = 0.95 -> 0.93 is between (0.95-0.05) and 0.95 -> Orange
// The severity badge is icon-only; its wording is in the tooltip.
const { rerender } = renderWithMF(0.95);
expect(screen.getByTitle('Solved \u2014 low margin')).toBeInTheDocument();
// 2. MF = 0.90 -> 0.93 is above 0.90 -> Red
rerender(
<ActionFeed
{...defaultProps}
actions={{ [actionId]: actionDetail }}
selectedActionIds={new Set([actionId])}
monitoringFactor={0.90}
/>
);
expect(screen.getByTitle('Still overloaded')).toBeInTheDocument();
// 3. MF = 1.00 -> 0.93 is below (1.00-0.05) -> Green
rerender(
<ActionFeed
{...defaultProps}
actions={{ [actionId]: actionDetail }}
selectedActionIds={new Set([actionId])}
monitoringFactor={1.00}
/>
);
expect(screen.getByTitle('Solves overload')).toBeInTheDocument();
});
it('displays load shedding description with MW, load name, and clickable voltage level', () => {
const actionId = 'load_shed_1';
const props = {
...defaultProps,
// Editor disclosure is gated on the viewing state — set
// selectedActionId so the load-shedding details panel
// (which carries the LOAD_1 / Shedding-on copy this test
// checks for) actually renders.
selectedActionId: actionId,
actions: {
[actionId]: {
description_unitaire: 'Shedding action on load',
rho_before: [1.0],
rho_after: [0.8],
max_rho: 0.8,
max_rho_line: 'LINE_A',
is_rho_reduction: true,
action_topology: { lines_ex_bus: {}, lines_or_bus: {}, gens_bus: {}, loads_bus: { LOAD_1: -1 } },
load_shedding_details: [
{ load_name: 'LOAD_1', voltage_level_id: 'VL_ALPHA', shedded_mw: 42.5 },
],
}
},
selectedActionIds: new Set([actionId]),
};
render(<ActionFeed {...props} />);
// Should show load shedding description with asset name
expect(screen.getByText(/Shedding on/)).toBeInTheDocument();
expect(screen.getByText('LOAD_1')).toBeInTheDocument();
// VL should be rendered as clickable badge button
const vlButtons = screen.getAllByText('VL_ALPHA');
expect(vlButtons.length).toBeGreaterThanOrEqual(1);
expect(vlButtons.every(el => el.tagName === 'BUTTON')).toBe(true);
});
it('shows VL badges from load_shedding_details instead of load name badges', () => {
const actionId = 'load_shed_2';
const props = {
...defaultProps,
actions: {
[actionId]: {
description_unitaire: 'Shedding action',
rho_before: [1.0],
rho_after: [0.8],
max_rho: 0.8,
max_rho_line: 'LINE_A',
is_rho_reduction: true,
action_topology: { lines_ex_bus: {}, lines_or_bus: {}, gens_bus: {}, loads_bus: { LOAD_X: -1 } },
load_shedding_details: [
{ load_name: 'LOAD_X', voltage_level_id: 'VL_BETA', shedded_mw: 10.0 },
],
}
},
selectedActionIds: new Set([actionId]),
};
render(<ActionFeed {...props} />);
// VL badge should be present (green VL badge)
const badges = screen.getAllByText('VL_BETA');
// There should be at least one button element (the badge) with VL_BETA text
const buttonBadges = badges.filter(el => el.tagName === 'BUTTON');
expect(buttonBadges.length).toBeGreaterThan(0);
});
it('displays multiple load shedding entries when action sheds multiple loads', () => {
const actionId = 'load_shed_multi';
const props = {
...defaultProps,
selectedActionId: actionId,
actions: {
[actionId]: {
description_unitaire: 'Multi-load shedding',
rho_before: [1.0],
rho_after: [0.7],
max_rho: 0.7,
max_rho_line: 'LINE_A',
is_rho_reduction: true,
action_topology: { lines_ex_bus: {}, lines_or_bus: {}, gens_bus: {}, loads_bus: { LOAD_A: -1, LOAD_B: -1 } },
load_shedding_details: [
{ load_name: 'LOAD_A', voltage_level_id: 'VL_1', shedded_mw: 20.0 },
{ load_name: 'LOAD_B', voltage_level_id: 'VL_2', shedded_mw: 15.3 },
],
}
},
selectedActionIds: new Set([actionId]),
};
render(<ActionFeed {...props} />);
// Both load names should be shown in the simplified format
expect(screen.getByText('LOAD_A')).toBeInTheDocument();
expect(screen.getByText('LOAD_B')).toBeInTheDocument();
// Both entries should have "Load shedding on" text
const lsTexts = screen.getAllByText(/Shedding on/);
expect(lsTexts.length).toBe(2);
});
it('clicking VL button in load shedding description triggers onAssetClick', () => {
const onAssetClick = vi.fn();
const actionId = 'load_shed_click';
const props = {
...defaultProps,
onAssetClick,
actions: {
[actionId]: {
description_unitaire: 'Shedding for click test',
rho_before: [1.0],
rho_after: [0.8],
max_rho: 0.8,
max_rho_line: 'LINE_A',
is_rho_reduction: true,
action_topology: { lines_ex_bus: {}, lines_or_bus: {}, gens_bus: {}, loads_bus: { LOAD_C: -1 } },
load_shedding_details: [
{ load_name: 'LOAD_C', voltage_level_id: 'VL_GAMMA', shedded_mw: 30.0 },
],
}
},
selectedActionIds: new Set([actionId]),
};
render(<ActionFeed {...props} />);
// Find the VL button in the load shedding description area (not the badge)
const vlButtons = screen.getAllByText('VL_GAMMA');
// Click the first one (description area)
fireEvent.click(vlButtons[0]);
expect(onAssetClick).toHaveBeenCalledWith(actionId, 'VL_GAMMA', 'action');
});
// ── MW Start column ────────────────────────────────────────────────────
it('shows MW Start column header in score table', async () => {
const props = {
...defaultProps,
actionScores: {
line_reconnection: {
scores: { act_reco: 8 },
mw_start: { act_reco: null },
}
},
};
render(<ActionFeed {...props} />);
fireEvent.click(screen.getByText('+ Manual Selection'));
expect(await screen.findByText('MW Start')).toBeInTheDocument();
});
it('shows numeric MW Start value for disco/pst/ls actions', async () => {
const props = {
...defaultProps,
actionScores: {
line_disconnection: {
scores: { disco_L1: 9 },
mw_start: { disco_L1: 125.4 },
}
},
};
render(<ActionFeed {...props} />);
fireEvent.click(screen.getByText('+ Manual Selection'));
expect(await screen.findByText('125.4')).toBeInTheDocument();
});
it('shows N/A for reconnection and close-coupling actions', async () => {
const props = {
...defaultProps,
actionScores: {
line_reconnection: {
scores: { reco_L2: 7 },
mw_start: { reco_L2: null },
}
},
};
render(<ActionFeed {...props} />);
fireEvent.click(screen.getByText('+ Manual Selection'));
expect(await screen.findByText('N/A')).toBeInTheDocument();
expect(screen.queryByText(/^\d+\.\d$/)).not.toBeInTheDocument();
});
it('shows N/A when mw_start field is absent for the action', async () => {
const props = {
...defaultProps,
actionScores: {
pst_tap_change: {
scores: { pst_act: 5 },
// no mw_start key at all
}
},
};
render(<ActionFeed {...props} />);
fireEvent.click(screen.getByText('+ Manual Selection'));
expect(await screen.findByText('N/A')).toBeInTheDocument();
});
it('shows MW Start for load shedding action', async () => {
const props = {
...defaultProps,
actionScores: {
load_shedding: {
scores: { ls_LOAD_1: 3 },
mw_start: { ls_LOAD_1: 75.0 },
}
},
};
render(<ActionFeed {...props} />);
fireEvent.click(screen.getByText('+ Manual Selection'));
expect(await screen.findByText('75.0')).toBeInTheDocument();
});
// ── Power reduction format (loads_p / gens_p) ──────────────────────────
it('displays load shedding details with new loads_p topology format', () => {
const actionId = 'load_shed_new_format';
const props = {
...defaultProps,
selectedActionId: actionId,
actions: {
[actionId]: {
description_unitaire: 'Load shedding (power reduction)',
rho_before: [1.0],
rho_after: [0.8],
max_rho: 0.8,
max_rho_line: 'LINE_A',
is_rho_reduction: true,
action_topology: { lines_ex_bus: {}, lines_or_bus: {}, gens_bus: {}, loads_bus: {}, loads_p: { LOAD_PR: 0.0 } },
load_shedding_details: [
{ load_name: 'LOAD_PR', voltage_level_id: 'VL_PR', shedded_mw: 55.3 },
],
}
},
selectedActionIds: new Set([actionId]),
};
render(<ActionFeed {...props} />);
// Simplified format: "Shedding on LOAD_PR in MW: [input]"
expect(screen.getByText(/Shedding on/)).toBeInTheDocument();
expect(screen.getByText('LOAD_PR')).toBeInTheDocument();
// VL badge should still be rendered
const vlButtons = screen.getAllByText('VL_PR');
expect(vlButtons.length).toBeGreaterThanOrEqual(1);
});
it('displays curtailment details with new gens_p topology format', () => {
const actionId = 'curtail_new_format';
const props = {
...defaultProps,
selectedActionId: actionId,
actions: {
[actionId]: {
description_unitaire: 'Curtailment (power reduction)',
rho_before: [1.0],
rho_after: [0.85],
max_rho: 0.85,
max_rho_line: 'LINE_A',
is_rho_reduction: true,
action_topology: { lines_ex_bus: {}, lines_or_bus: {}, gens_bus: {}, loads_bus: {}, gens_p: { WIND_1: 0.0 } },
curtailment_details: [
{ gen_name: 'WIND_1', voltage_level_id: 'VL_WIND', curtailed_mw: 80.0 },
],
}
},
selectedActionIds: new Set([actionId]),
};
render(<ActionFeed {...props} />);
// Simplified format: "Curtailment on WIND_1 in MW: [input]"
expect(screen.getByText(/Curtailment on/)).toBeInTheDocument();
expect(screen.getByText('WIND_1')).toBeInTheDocument();
// VL badge should still be rendered
const vlButtons = screen.getAllByText('VL_WIND');
expect(vlButtons.length).toBeGreaterThanOrEqual(1);
});
it('displays load shedding with loads_p and curtailment with gens_p in same action list', () => {
// Only one card can be the viewing card at a time, so we
// assert each editor through a focused rerender rather than
// expecting both editor panels open simultaneously.
const lsId = 'ls_new_1';
const rcId = 'rc_new_1';
const props = {
...defaultProps,
actions: {
[lsId]: {
description_unitaire: 'Load shedding power reduction',
rho_before: [1.0],
rho_after: [0.8],
max_rho: 0.8,
max_rho_line: 'LINE_A',
is_rho_reduction: true,
action_topology: { lines_ex_bus: {}, lines_or_bus: {}, gens_bus: {}, loads_bus: {}, loads_p: { LOAD_NEW: 0.0 } },
load_shedding_details: [
{ load_name: 'LOAD_NEW', voltage_level_id: 'VL_LS', shedded_mw: 33.0 },
],
},
[rcId]: {
description_unitaire: 'Curtailment power reduction',
rho_before: [1.0],
rho_after: [0.85],
max_rho: 0.85,
max_rho_line: 'LINE_A',
is_rho_reduction: true,
action_topology: { lines_ex_bus: {}, lines_or_bus: {}, gens_bus: {}, loads_bus: {}, gens_p: { GEN_NEW: 0.0 } },
curtailment_details: [
{ gen_name: 'GEN_NEW', voltage_level_id: 'VL_RC', curtailed_mw: 66.0 },
],
}
},
selectedActionIds: new Set([lsId, rcId]),
};
// Both cards must be in the list regardless of viewing state.
const { rerender } = render(<ActionFeed {...props} selectedActionId={lsId} />);
expect(screen.getByTestId(`action-card-${lsId}`)).toBeInTheDocument();
expect(screen.getByTestId(`action-card-${rcId}`)).toBeInTheDocument();
// Viewing the LS card discloses the load-shedding editor.
expect(screen.getByText(/Shedding on/)).toBeInTheDocument();
expect(screen.getByText('LOAD_NEW')).toBeInTheDocument();
// Viewing the RC card discloses the curtailment editor.
rerender(<ActionFeed {...props} selectedActionId={rcId} />);
expect(screen.getByText(/Curtailment on/)).toBeInTheDocument();
expect(screen.getByText('GEN_NEW')).toBeInTheDocument();
});
it('shows Target MW column in score table for load shedding type', async () => {
const props = {
...defaultProps,
actionScores: {
load_shedding: {
scores: { 'load_shedding_LOAD_1': 5.0 },
params: {},
mw_start: { 'load_shedding_LOAD_1': 100.0 },
}
},
};
render(<ActionFeed {...props} />);
fireEvent.click(screen.getByText('+ Manual Selection'));
expect(await screen.findByText('Target MW')).toBeInTheDocument();
expect(screen.getByTestId('target-mw-load_shedding_LOAD_1')).toBeInTheDocument();
});
it('shows Target MW column in score table for renewable curtailment type', async () => {
const props = {
...defaultProps,
actionScores: {
renewable_curtailment: {
scores: { 'curtail_GEN_1': 3.0 },
params: {},
mw_start: { 'curtail_GEN_1': 80.0 },
}
},
};
render(<ActionFeed {...props} />);
fireEvent.click(screen.getByText('+ Manual Selection'));
expect(await screen.findByText('Target MW')).toBeInTheDocument();
expect(screen.getByTestId('target-mw-curtail_GEN_1')).toBeInTheDocument();
});
it('does NOT show Target MW column for line_disconnection type', async () => {
const props = {
...defaultProps,
actionScores: {
line_disconnection: {
scores: { 'disco_LINE_1': 7.0 },
params: {},
mw_start: { 'disco_LINE_1': null },
}
},
};
render(<ActionFeed {...props} />);
fireEvent.click(screen.getByText('+ Manual Selection'));
await screen.findByText('LINE DISCONNECTION');
expect(screen.queryByText('Target MW')).not.toBeInTheDocument();
});
it('shows editable MW and re-simulate button on load shedding action card', () => {
// Parameter editors are progressive-disclosure (PR
// claude/redesign-actioncard-…): they only render on the
// currently-viewed card to keep at-rest cards to five
// fields. Set `selectedActionId` so the editor is visible.
const actionId = 'load_shedding_LOAD_X';
const props = {
...defaultProps,
selectedActionId: actionId,
actions: {
[actionId]: {
description_unitaire: 'Load shedding on LOAD_X',
rho_before: [0.95],
rho_after: [0.70],
max_rho: 0.70,
max_rho_line: 'LINE_1',
is_rho_reduction: true,
non_convergence: null,
load_shedding_details: [{ load_name: 'LOAD_X', voltage_level_id: 'VL_A', shedded_mw: 42.5 }],
action_topology: { ...emptyTopo, loads_p: { LOAD_X: 0.0 } },
} as ActionDetail,
},
};
render(<ActionFeed {...props} />);
expect(screen.getByTestId(`edit-mw-${actionId}`)).toBeInTheDocument();
expect(screen.getByTestId(`resimulate-${actionId}`)).toBeInTheDocument();
expect(screen.getByTestId(`resimulate-${actionId}`)).toHaveTextContent('Re-simulate');
});
it('shows editable MW and re-simulate button on curtailment action card', () => {
const actionId = 'curtail_GEN_Y';
const props = {
...defaultProps,
selectedActionId: actionId,
actions: {
[actionId]: {
description_unitaire: 'Curtailment on GEN_Y',
rho_before: [0.95],
rho_after: [0.80],
max_rho: 0.80,
max_rho_line: 'LINE_1',
is_rho_reduction: true,
non_convergence: null,
curtailment_details: [{ gen_name: 'GEN_Y', voltage_level_id: 'VL_W', curtailed_mw: 60.0 }],
action_topology: { ...emptyTopo, gens_p: { GEN_Y: 0.0 } },
} as ActionDetail,
},
};
render(<ActionFeed {...props} />);
expect(screen.getByTestId(`edit-mw-${actionId}`)).toBeInTheDocument();
expect(screen.getByTestId(`resimulate-${actionId}`)).toBeInTheDocument();
});
it('calls simulateManualAction with target_mw when re-simulate is clicked', async () => {
const actionId = 'load_shedding_LOAD_X';
const mockResult = {
action_id: actionId,
description_unitaire: 'Load shedding on LOAD_X',
rho_before: [0.95],
rho_after: [0.60],
max_rho: 0.60,
max_rho_line: 'LINE_1',
is_rho_reduction: true,
non_convergence: null,
lines_overloaded: ['LINE_1'],
load_shedding_details: [{ load_name: 'LOAD_X', voltage_level_id: 'VL_A', shedded_mw: 25.0 }],
};
(api.simulateManualAction as ReturnType<typeof vi.fn>).mockResolvedValueOnce(mockResult);
const props = {
...defaultProps,
selectedActionId: actionId,
actions: {
[actionId]: {
description_unitaire: 'Load shedding on LOAD_X',
rho_before: [0.95],
rho_after: [0.70],
max_rho: 0.70,
max_rho_line: 'LINE_1',
is_rho_reduction: true,
non_convergence: null,
load_shedding_details: [{ load_name: 'LOAD_X', voltage_level_id: 'VL_A', shedded_mw: 42.5 }],
action_topology: { ...emptyTopo, loads_p: { LOAD_X: 0.0 } },
} as ActionDetail,
},
};
render(<ActionFeed {...props} />);
// Change the MW input value
const mwInput = screen.getByTestId(`edit-mw-${actionId}`);
fireEvent.change(mwInput, { target: { value: '25' } });
// Click re-simulate
fireEvent.click(screen.getByTestId(`resimulate-${actionId}`));
await waitFor(() => {
expect(api.simulateManualAction).toHaveBeenCalledWith(
actionId,
'LINE_1',
expect.anything(),
['LINE_1'],
25,
);
});
});
// Bug 5: re-simulating a Suggested LS / RC / PST card must dispatch
// through the `onActionResimulated` callback (which preserves the
// action's current bucket) — NEVER `onManualActionAdded`, which would
// silently promote the card into Selected Actions.
describe('Re-simulate dispatches to onActionResimulated (Bug 5)', () => {
const buildLsProps = () => {
const actionId = 'load_shedding_LOAD_B5';
const props = {
...defaultProps,
selectedActionId: actionId,
onManualActionAdded: vi.fn(),
onActionResimulated: vi.fn(),
actions: {
[actionId]: {
description_unitaire: 'ls B5',
rho_before: [0.95],
rho_after: [0.7],
max_rho: 0.7,
max_rho_line: 'LINE_1',
is_rho_reduction: true,
non_convergence: null,
load_shedding_details: [
{ load_name: 'LOAD_B5', voltage_level_id: 'VL', shedded_mw: 6.4 },
],
action_topology: { ...emptyTopo, loads_p: { LOAD_B5: 0.0 } },
} as ActionDetail,
},
};
const mockResult = {
action_id: actionId,
description_unitaire: 'ls B5',
rho_before: [0.95],
rho_after: [0.5],
max_rho: 0.5,
max_rho_line: 'LINE_1',
is_rho_reduction: true,
non_convergence: null,
lines_overloaded: ['LINE_1'],
lines_overloaded_after: [],
load_shedding_details: [
{ load_name: 'LOAD_B5', voltage_level_id: 'VL', shedded_mw: 5.4 },
],
};
return { actionId, props, mockResult };
};
it('calls onActionResimulated (not onManualActionAdded) on LS re-simulate', async () => {
const { actionId, props, mockResult } = buildLsProps();
(api.simulateManualAction as ReturnType<typeof vi.fn>).mockResolvedValueOnce(mockResult);
render(<ActionFeed {...props} />);
fireEvent.change(screen.getByTestId(`edit-mw-${actionId}`), { target: { value: '5.4' } });
fireEvent.click(screen.getByTestId(`resimulate-${actionId}`));
await waitFor(() => {
expect(props.onActionResimulated).toHaveBeenCalledTimes(1);
});
expect(props.onActionResimulated).toHaveBeenCalledWith(
actionId,
expect.objectContaining({
lines_overloaded_after: [],
}),
['LINE_1'],
);
// Critical: the "add manual action" callback (which would
// promote the card into Selected Actions) must NOT have been
// invoked.
expect(props.onManualActionAdded).not.toHaveBeenCalled();
});
it('calls onActionResimulated (not onManualActionAdded) on PST tap re-simulate', async () => {
const pstActionId = 'pst_B5';
const props = {
...defaultProps,
selectedActionId: pstActionId,
onManualActionAdded: vi.fn(),
onActionResimulated: vi.fn(),
actions: {
[pstActionId]: {
description_unitaire: 'pst B5',
rho_before: [0.95],
rho_after: [0.7],
max_rho: 0.7,
max_rho_line: 'LINE_1',
is_rho_reduction: true,
non_convergence: null,
pst_details: [
{ pst_name: 'PST_B5', tap_position: 3, low_tap: -5, high_tap: 5 },
],
action_topology: { ...emptyTopo, pst_tap: { PST_B5: 0 } },
} as ActionDetail,
},
};
const mockResult = {
action_id: pstActionId,
description_unitaire: 'pst B5',
rho_before: [0.95],
rho_after: [0.5],
max_rho: 0.5,
max_rho_line: 'LINE_1',
is_rho_reduction: true,
non_convergence: null,
lines_overloaded: ['LINE_1'],
lines_overloaded_after: [],
pst_details: [
{ pst_name: 'PST_B5', tap_position: 4, low_tap: -5, high_tap: 5 },
],
};
(api.simulateManualAction as ReturnType<typeof vi.fn>).mockResolvedValueOnce(mockResult);
render(<ActionFeed {...props} />);
fireEvent.change(screen.getByTestId(`edit-tap-${pstActionId}`), { target: { value: '4' } });
fireEvent.click(screen.getByTestId(`resimulate-tap-${pstActionId}`));
await waitFor(() => {
expect(props.onActionResimulated).toHaveBeenCalledTimes(1);
});
expect(props.onManualActionAdded).not.toHaveBeenCalled();
});
});
// =========================================================================
// Re-simulation interaction logging (action_mw_resimulated / pst_tap_resimulated)
// =========================================================================
//
// These events carry the raw user-entered target value so a replay
// agent can type the same MW / tap into the card's input before
// clicking Re-simulate. They are emitted from
// ActionFeed.handleResimulate / handleResimulateTap — NOT from the
// useActions hook, which used to log a mistyped
// 'manual_action_simulated' event.
// -------------------------------------------------------------------------
describe('Re-simulation interaction logging', () => {
beforeEach(() => {
interactionLogger.clear();
});
const buildLsProps = () => {
const actionId = 'load_shedding_LOG_A1';
const props = {
...defaultProps,
selectedActionId: actionId,
onActionResimulated: vi.fn(),
actions: {
[actionId]: {
description_unitaire: 'ls A1',
rho_before: [0.95],
rho_after: [0.7],
max_rho: 0.7,
max_rho_line: 'LINE_1',
is_rho_reduction: true,
non_convergence: null,
load_shedding_details: [
{ load_name: 'LOG_A1', voltage_level_id: 'VL', shedded_mw: 6.4 },
],
action_topology: { ...emptyTopo, loads_p: { LOG_A1: 0.0 } },
} as ActionDetail,
},
};
const mockResult = {
action_id: actionId,
description_unitaire: 'ls A1',
rho_before: [0.95],
rho_after: [0.55],
max_rho: 0.55,
max_rho_line: 'LINE_1',
is_rho_reduction: true,
non_convergence: null,
lines_overloaded: ['LINE_1'],
lines_overloaded_after: [],
load_shedding_details: [
{ load_name: 'LOG_A1', voltage_level_id: 'VL', shedded_mw: 5.4 },
],
};
return { actionId, props, mockResult };
};
it('records action_mw_resimulated with the exact user-entered target MW on LS re-simulate', async () => {
const { actionId, props, mockResult } = buildLsProps();
(api.simulateManualAction as ReturnType<typeof vi.fn>).mockResolvedValueOnce(mockResult);
render(<ActionFeed {...props} />);
fireEvent.change(screen.getByTestId(`edit-mw-${actionId}`), { target: { value: '5.4' } });
fireEvent.click(screen.getByTestId(`resimulate-${actionId}`));
await waitFor(() => {
expect(props.onActionResimulated).toHaveBeenCalledTimes(1);
});
const log = interactionLogger.getLog();
const evt = log.find(e => e.type === 'action_mw_resimulated');
expect(evt).toBeDefined();
expect(evt!.details).toEqual({
action_id: actionId,
// parseFloat of the input string, before any backend
// clamping — matches the value the user typed.
target_mw: 5.4,
});
});
it('does NOT record manual_action_simulated when re-simulating load shedding', async () => {
const { actionId, props, mockResult } = buildLsProps();
(api.simulateManualAction as ReturnType<typeof vi.fn>).mockResolvedValueOnce(mockResult);
render(<ActionFeed {...props} />);
fireEvent.change(screen.getByTestId(`edit-mw-${actionId}`), { target: { value: '5.4' } });
fireEvent.click(screen.getByTestId(`resimulate-${actionId}`));
await waitFor(() => {
expect(props.onActionResimulated).toHaveBeenCalledTimes(1);
});
// Regression guard: re-simulation is a distinct gesture
// and must NOT be logged as a manual action (which would
// mislead a replay agent into promoting the card into
// Selected Actions).
const log = interactionLogger.getLog();
expect(log.some(e => e.type === 'manual_action_simulated')).toBe(false);
});
it('records pst_tap_resimulated with the exact user-entered tap on PST re-simulate', async () => {
const pstActionId = 'pst_A1';
const props = {
...defaultProps,
selectedActionId: pstActionId,
onActionResimulated: vi.fn(),
actions: {
[pstActionId]: {
description_unitaire: 'pst A1',
rho_before: [0.95],
rho_after: [0.7],
max_rho: 0.7,
max_rho_line: 'LINE_1',
is_rho_reduction: true,
non_convergence: null,
pst_details: [
{ pst_name: 'PST_A1', tap_position: 3, low_tap: -5, high_tap: 5 },
],
action_topology: { ...emptyTopo, pst_tap: { PST_A1: 0 } },
} as ActionDetail,
},
};
const mockResult = {
action_id: pstActionId,
description_unitaire: 'pst A1',
rho_before: [0.95],
rho_after: [0.5],
max_rho: 0.5,
max_rho_line: 'LINE_1',
is_rho_reduction: true,
non_convergence: null,
lines_overloaded: ['LINE_1'],
lines_overloaded_after: [],
pst_details: [
{ pst_name: 'PST_A1', tap_position: 4, low_tap: -5, high_tap: 5 },
],
};
(api.simulateManualAction as ReturnType<typeof vi.fn>).mockResolvedValueOnce(mockResult);
render(<ActionFeed {...props} />);
fireEvent.change(screen.getByTestId(`edit-tap-${pstActionId}`), { target: { value: '4' } });
fireEvent.click(screen.getByTestId(`resimulate-tap-${pstActionId}`));
await waitFor(() => {
expect(props.onActionResimulated).toHaveBeenCalledTimes(1);
});
const log = interactionLogger.getLog();
const evt = log.find(e => e.type === 'pst_tap_resimulated');
expect(evt).toBeDefined();
expect(evt!.details).toEqual({
action_id: pstActionId,
target_tap: 4,
});
// And again: a PST re-simulation must not masquerade as a
// manual action.
expect(log.some(e => e.type === 'manual_action_simulated')).toBe(false);
expect(log.some(e => e.type === 'action_mw_resimulated')).toBe(false);
});
it('records target_mw as the parsed float even when the user types extra decimals', async () => {
const { actionId, props, mockResult } = buildLsProps();
(api.simulateManualAction as ReturnType<typeof vi.fn>).mockResolvedValueOnce(mockResult);
render(<ActionFeed {...props} />);
fireEvent.change(screen.getByTestId(`edit-mw-${actionId}`), { target: { value: '5.400' } });
fireEvent.click(screen.getByTestId(`resimulate-${actionId}`));
await waitFor(() => {
expect(props.onActionResimulated).toHaveBeenCalledTimes(1);
});
const log = interactionLogger.getLog();
const evt = log.find(e => e.type === 'action_mw_resimulated');
expect(evt).toBeDefined();
expect(evt!.details.target_mw).toBe(5.4);
});
});
// =========================================================================
// PST Tap Start / Target Tap — score table tests
// =========================================================================
describe('PST score table - Tap Start and Target Tap', () => {
const pstActionId = 'pst_tap_ARKA_TD_661_inc2';
const pstActionId2 = 'pst_tap_PRAGNY661_inc2';
/** Helper: build actionScores for a PST type with params + tap_start */
function makePstScores(overrides?: {
params?: Record<string, Record<string, unknown>>;
tap_start?: Record<string, { pst_name: string; tap: number; low_tap: number | null; high_tap: number | null } | null>;
scores?: Record<string, number>;
}) {
return {
pst_tap_change: {
scores: overrides?.scores ?? { [pstActionId]: -9.23, [pstActionId2]: -0.07 },
params: overrides?.params ?? {
[pstActionId]: { 'pst_tap': 'ARKA TD 661', 'selected_pst_tap': 29, 'previous tap': 27 },
[pstActionId2]: { 'pst_tap': 'PRAGNY661', 'selected_pst_tap': 10, 'previous tap': 8 },
},
tap_start: overrides?.tap_start ?? {
[pstActionId]: { pst_name: 'ARKA TD 661', tap: 29, low_tap: 8, high_tap: 31 },
[pstActionId2]: { pst_name: 'PRAGNY661', tap: 10, low_tap: 0, high_tap: 16 },
},
},
};
}
/** Helper: build a computed PST ActionDetail */
function makePstAction(tapPosition: number, pstName = 'ARKA TD 661'): ActionDetail {
return {
description_unitaire: `Variation PST ${pstName}`,
rho_before: [1.1],
rho_after: [0.95],
max_rho: 0.95,
max_rho_line: 'LINE_A',
is_rho_reduction: true,
action_topology: { ...emptyTopo, pst_tap: { [pstName]: tapPosition } },
pst_details: [{ pst_name: pstName, tap_position: tapPosition, low_tap: 8, high_tap: 31 }],
} as ActionDetail;
}
it('renders "Tap Start" and "Target Tap" headers for PST type in score table', async () => {
const props = {
...defaultProps,
actionScores: makePstScores(),
};
render(<ActionFeed {...props} />);
fireEvent.click(screen.getByText('+ Manual Selection'));
expect(await screen.findByText('Tap Start')).toBeInTheDocument();
expect(screen.getByText('Target Tap')).toBeInTheDocument();
});
it('shows "previous tap" from params as Tap Start, NOT the action target tap', async () => {
// The key test: params has "previous tap": 27, but tap_start has tap: 29 (action target).
// Tap Start must display 27 (the N-state value from params), NOT 29.
const props = {
...defaultProps,
actionScores: makePstScores(),
};
render(<ActionFeed {...props} />);
fireEvent.click(screen.getByText('+ Manual Selection'));
await screen.findByText('Tap Start');
// Find the table rows for PST actions
const rows = screen.getAllByRole('row');
// Find the row containing our PST action
const actionRow = rows.find(r => r.textContent?.includes(pstActionId));
expect(actionRow).toBeDefined();
// Tap Start cell should show 27 (from params "previous tap"), not 29 (from tap_start.tap)
expect(actionRow!.textContent).toContain('27');
// It should NOT show 29 as the Tap Start display value
// (29 may appear in Target Tap input, but the start text should be 27)
});
it('shows "previous tap" from params for second PST action too', async () => {
const props = {
...defaultProps,
actionScores: makePstScores(),
};
render(<ActionFeed {...props} />);
fireEvent.click(screen.getByText('+ Manual Selection'));
await screen.findByText('Tap Start');
const rows = screen.getAllByRole('row');
const actionRow2 = rows.find(r => r.textContent?.includes(pstActionId2));
expect(actionRow2).toBeDefined();
// Should show 8 (previous tap), not 10 (selected_pst_tap / tap_start)
expect(actionRow2!.textContent).toContain('8');
});
it('does NOT show N/A when params has "previous tap" even if action is not yet simulated', async () => {
// Actions not yet computed (not in actions dict) should still show Tap Start
// from params "previous tap" — should never be N/A when params exist
const props = {
...defaultProps,
actions: {}, // No computed actions
actionScores: makePstScores(),
};
render(<ActionFeed {...props} />);
fireEvent.click(screen.getByText('+ Manual Selection'));
await screen.findByText('Tap Start');
const rows = screen.getAllByRole('row');
const actionRow = rows.find(r => r.textContent?.includes(pstActionId));
expect(actionRow).toBeDefined();
// Must show 27, NOT N/A
expect(actionRow!.textContent).toContain('27');
expect(actionRow!.textContent).not.toContain('N/A');
});
it('does NOT show N/A for second PST action when params exist but action is not simulated', async () => {
const props = {
...defaultProps,
actions: {},
actionScores: makePstScores(),
};
render(<ActionFeed {...props} />);
fireEvent.click(screen.getByText('+ Manual Selection'));
await screen.findByText('Tap Start');
const rows = screen.getAllByRole('row');
const actionRow2 = rows.find(r => r.textContent?.includes(pstActionId2));
expect(actionRow2).toBeDefined();
expect(actionRow2!.textContent).toContain('8');
expect(actionRow2!.textContent).not.toContain('N/A');
});
it('Tap Start remains stable at "previous tap" after simulation with different target', async () => {
// After simulation, pst_details has tap_position: 29, but Tap Start must stay 27
const props = {
...defaultProps,
actions: {
[pstActionId]: makePstAction(29), // simulated with target tap 29
},
actionScores: makePstScores(),
};
render(<ActionFeed {...props} />);
fireEvent.click(screen.getByText('+ Manual Selection'));
await screen.findByText('Tap Start');
const rows = screen.getAllByRole('row');
const actionRow = rows.find(r => r.textContent?.includes(pstActionId));
expect(actionRow).toBeDefined();
// The Tap Start cell should contain "27", not "29"
// Get all td cells in the row
const cells = actionRow!.querySelectorAll('td');
// Second cell is Tap Start (first is Action name)
// Score sits in column 2 since the manual-selection score
// table was reorganised so the ranking is visible right
// after the action name. Tap Start now lives in column 3.
const tapStartCell = cells[2];
expect(tapStartCell).toBeDefined();
// The textContent of the Tap Start cell should start with 27
expect(tapStartCell.textContent).toMatch(/^27/);
});
it('Tap Start stays at "previous tap" even after re-simulation with a new tap', async () => {
// User re-simulated with tap 31. pst_details.tap_position is now 31.
// But Tap Start must still show 27 from params.
const props = {
...defaultProps,
actions: {
[pstActionId]: makePstAction(31), // re-simulated with target tap 31
},
actionScores: makePstScores(),
};
render(<ActionFeed {...props} />);
fireEvent.click(screen.getByText('+ Manual Selection'));
await screen.findByText('Tap Start');
const rows = screen.getAllByRole('row');
const actionRow = rows.find(r => r.textContent?.includes(pstActionId));
const cells = actionRow!.querySelectorAll('td');
// Score sits in column 2 since the manual-selection score
// table was reorganised so the ranking is visible right
// after the action name. Tap Start now lives in column 3.
const tapStartCell = cells[2];
expect(tapStartCell.textContent).toMatch(/^27/);
});
it('Target Tap input defaults to "previous tap" (start tap) when action not yet simulated', async () => {
// When no action is computed, Target Tap defaults to start tap (previous tap)
const props = {
...defaultProps,
actions: {}, // NOT computed
actionScores: makePstScores(),
};
render(<ActionFeed {...props} />);
fireEvent.click(screen.getByText('+ Manual Selection'));
await screen.findByText('Tap Start');
const targetInput = screen.getByTestId(`target-tap-${pstActionId}`) as HTMLInputElement;
// Should default to 27 (the previous tap / start tap) since not yet simulated
expect(targetInput.value).toBe('27');
});
it('shows tap range [low..high] next to Tap Start', async () => {
const props = {
...defaultProps,
actionScores: makePstScores(),
};
render(<ActionFeed {...props} />);
fireEvent.click(screen.getByText('+ Manual Selection'));
await screen.findByText('Tap Start');
const rows = screen.getAllByRole('row');
const actionRow = rows.find(r => r.textContent?.includes(pstActionId));
expect(actionRow).toBeDefined();
// Should show range from tap_start bounds
expect(actionRow!.textContent).toContain('[8..31]');
});
it('falls back to tap_start when params has no "previous tap"', async () => {
const props = {
...defaultProps,
actionScores: makePstScores({
params: {
[pstActionId]: { 'pst_tap': 'ARKA TD 661', 'selected_pst_tap': 29 },
// No 'previous tap' key
},
scores: { [pstActionId]: -9.23 },
}),
};
render(<ActionFeed {...props} />);
fireEvent.click(screen.getByText('+ Manual Selection'));
await screen.findByText('Tap Start');
const rows = screen.getAllByRole('row');
const actionRow = rows.find(r => r.textContent?.includes(pstActionId));
expect(actionRow).toBeDefined();
// Falls back to tap_start which has tap: 29
expect(actionRow!.textContent).toContain('29');
expect(actionRow!.textContent).not.toContain('N/A');
});
it('falls back to computedPst when neither params nor tap_start available', async () => {
const props = {
...defaultProps,
actions: {
[pstActionId]: makePstAction(29),
},
actionScores: makePstScores({
params: {}, // No params
tap_start: {}, // No tap_start
scores: { [pstActionId]: -9.23 },
}),
};
render(<ActionFeed {...props} />);
fireEvent.click(screen.getByText('+ Manual Selection'));
await screen.findByText('Tap Start');
const rows = screen.getAllByRole('row');
const actionRow = rows.find(r => r.textContent?.includes(pstActionId));
expect(actionRow).toBeDefined();
// Falls back to pst_details[0].tap_position which is 29
expect(actionRow!.textContent).toContain('29');
});
it('shows N/A only when no params, no tap_start, and no pst_details', async () => {
const props = {
...defaultProps,
actions: {}, // Not computed
actionScores: makePstScores({
params: {},
tap_start: {},
scores: { [pstActionId]: -9.23 },
}),
};
render(<ActionFeed {...props} />);
fireEvent.click(screen.getByText('+ Manual Selection'));
await screen.findByText('Tap Start');
const rows = screen.getAllByRole('row');
const actionRow = rows.find(r => r.textContent?.includes(pstActionId));
expect(actionRow).toBeDefined();
expect(actionRow!.textContent).toContain('N/A');
});
it('syncs Target Tap input change to cardEditTap state', async () => {
const props = {
...defaultProps,
actionScores: makePstScores(),
};
render(<ActionFeed {...props} />);
fireEvent.click(screen.getByText('+ Manual Selection'));
await screen.findByText('Tap Start');
const targetInput = screen.getByTestId(`target-tap-${pstActionId}`) as HTMLInputElement;
fireEvent.change(targetInput, { target: { value: '30' } });
expect(targetInput.value).toBe('30');
});
it('reads "previous_tap" (underscore variant) from params as Tap Start', async () => {
const props = {
...defaultProps,
actionScores: makePstScores({
params: {
[pstActionId]: { 'pst_tap': 'ARKA TD 661', 'selected_pst_tap': 29, 'previous_tap': 27 },
},
scores: { [pstActionId]: -9.23 },
}),
};
render(<ActionFeed {...props} />);
fireEvent.click(screen.getByText('+ Manual Selection'));
await screen.findByText('Tap Start');
const rows = screen.getAllByRole('row');
const actionRow = rows.find(r => r.textContent?.includes(pstActionId));
expect(actionRow).toBeDefined();
const cells = actionRow!.querySelectorAll('td');
// Score sits in column 2 since the manual-selection score
// table was reorganised so the ranking is visible right
// after the action name. Tap Start now lives in column 3.
const tapStartCell = cells[2];
expect(tapStartCell.textContent).toMatch(/^27/);
});
it('reads "previousTap" (camelCase variant) from params as Tap Start', async () => {
const props = {
...defaultProps,
actionScores: makePstScores({
params: {
[pstActionId]: { 'pst_tap': 'ARKA TD 661', 'selected_pst_tap': 29, 'previousTap': 27 },
},
scores: { [pstActionId]: -9.23 },
}),
};
render(<ActionFeed {...props} />);
fireEvent.click(screen.getByText('+ Manual Selection'));
await screen.findByText('Tap Start');
const rows = screen.getAllByRole('row');
const actionRow = rows.find(r => r.textContent?.includes(pstActionId));
expect(actionRow).toBeDefined();
const cells = actionRow!.querySelectorAll('td');
// Score sits in column 2 since the manual-selection score
// table was reorganised so the ranking is visible right
// after the action name. Tap Start now lives in column 3.
const tapStartCell = cells[2];
expect(tapStartCell.textContent).toMatch(/^27/);
});
it('Target Tap defaults to simulated tap (not start tap) when action is computed', async () => {
// Action was simulated with tap 29. Target Tap should show 29, not 27 (start tap).
const props = {
...defaultProps,
actions: {
[pstActionId]: makePstAction(29), // simulated with target tap 29
},
actionScores: makePstScores(),
};
render(<ActionFeed {...props} />);
fireEvent.click(screen.getByText('+ Manual Selection'));
await screen.findByText('Tap Start');
const targetInput = screen.getByTestId(`target-tap-${pstActionId}`) as HTMLInputElement;
// Must be 29 (simulated tap), NOT 27 (start/previous tap)
expect(targetInput.value).toBe('29');
});
it('Target Tap defaults to start tap when action is NOT yet simulated', async () => {
// Action not yet computed — Target Tap should show 27 (the start tap)
const props = {
...defaultProps,
actions: {}, // not computed
actionScores: makePstScores(),
};
render(<ActionFeed {...props} />);
fireEvent.click(screen.getByText('+ Manual Selection'));
await screen.findByText('Tap Start');
const targetInput = screen.getByTestId(`target-tap-${pstActionId}`) as HTMLInputElement;
expect(targetInput.value).toBe('27');
});
it('Target Tap updates to re-simulated tap value (31) when action re-simulated', async () => {
// After re-simulation with tap 31, pst_details has tap_position 31
const props = {
...defaultProps,
actions: {
[pstActionId]: makePstAction(31), // re-simulated with tap 31
},
actionScores: makePstScores(),
};
render(<ActionFeed {...props} />);
fireEvent.click(screen.getByText('+ Manual Selection'));
await screen.findByText('Tap Start');
const targetInput = screen.getByTestId(`target-tap-${pstActionId}`) as HTMLInputElement;
// Must show 31 (latest simulated tap), NOT 27 (start) or 29 (first sim)
expect(targetInput.value).toBe('31');
});
it('Tap Start stays at 27 while Target Tap shows 29 for a computed action', async () => {
// Verifies both columns are independently correct
const props = {
...defaultProps,
actions: {
[pstActionId]: makePstAction(29),
},
actionScores: makePstScores(),
};
render(<ActionFeed {...props} />);
fireEvent.click(screen.getByText('+ Manual Selection'));
await screen.findByText('Tap Start');
// Tap Start cell should show 27
const rows = screen.getAllByRole('row');
const actionRow = rows.find(r => r.textContent?.includes(pstActionId));
const cells = actionRow!.querySelectorAll('td');
// Score sits in column 2 since the manual-selection score
// table was reorganised so the ranking is visible right
// after the action name. Tap Start now lives in column 3.
const tapStartCell = cells[2];
expect(tapStartCell.textContent).toMatch(/^27/);
// Target Tap input should show 29
const targetInput = screen.getByTestId(`target-tap-${pstActionId}`) as HTMLInputElement;
expect(targetInput.value).toBe('29');
});
it('user editing Target Tap in score table overrides simulated tap', async () => {
const props = {
...defaultProps,
actions: {
[pstActionId]: makePstAction(29),
},
actionScores: makePstScores(),
};
render(<ActionFeed {...props} />);
fireEvent.click(screen.getByText('+ Manual Selection'));
await screen.findByText('Tap Start');
const targetInput = screen.getByTestId(`target-tap-${pstActionId}`) as HTMLInputElement;
expect(targetInput.value).toBe('29'); // starts at simulated
fireEvent.change(targetInput, { target: { value: '25' } });
expect(targetInput.value).toBe('25'); // user override
});
it('re-simulate button in score table calls simulateManualAction with target_tap', async () => {
(api.simulateManualAction as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
action_id: pstActionId,
description_unitaire: 'Variation PST ARKA TD 661',
rho_before: [1.1],
rho_after: [0.9],
max_rho: 0.9,
max_rho_line: 'LINE_A',
is_rho_reduction: true,
non_convergence: null,
lines_overloaded: ['LINE_1'],
pst_details: [{ pst_name: 'ARKA TD 661', tap_position: 30, low_tap: 8, high_tap: 31 }],
});
const props = {
...defaultProps,
actions: {
[pstActionId]: makePstAction(29),
},
actionScores: makePstScores(),
};
render(<ActionFeed {...props} />);
fireEvent.click(screen.getByText('+ Manual Selection'));
await screen.findByText('Tap Start');
// Change target tap to 30
const targetInput = screen.getByTestId(`target-tap-${pstActionId}`) as HTMLInputElement;
fireEvent.change(targetInput, { target: { value: '30' } });
// Click the row to trigger re-simulation
const rows = screen.getAllByRole('row');
const actionRow = rows.find(r => r.textContent?.includes(pstActionId));
fireEvent.click(actionRow!);
await waitFor(() => {
expect(api.simulateManualAction).toHaveBeenCalledWith(
pstActionId,
'LINE_1',
expect.anything(),
['LINE_1'],
null,
30,
);
});
});
});
// =========================================================================
// Combined estimation refresh on re-simulation
// =========================================================================
describe('Combined estimation refresh on re-simulation', () => {
beforeEach(() => {
vi.mocked(api.simulateManualAction).mockReset();
vi.mocked(api.computeSuperposition).mockReset();
});
const actionId = 'load_shedding_LOAD_X';
const otherActionId = 'line_reco_1';
const pairId = `${actionId}+${otherActionId}`;
const baseCombined: CombinedAction = {
action1_id: actionId,
action2_id: otherActionId,
betas: [0.5],
p_or_combined: [100],
max_rho: 0.85,
max_rho_line: 'LINE_1',
is_rho_reduction: true,
description: 'Combined action',
rho_after: [0.85],
rho_before: [1.05],
estimated_max_rho: 0.85,
estimated_max_rho_line: 'LINE_1',
};
it('calls computeSuperposition for related combined pairs after MW re-simulation', async () => {
const mockSimResult = {
action_id: actionId,
description_unitaire: 'Load shedding on LOAD_X',
rho_before: [0.95],
rho_after: [0.60],
max_rho: 0.60,
max_rho_line: 'LINE_1',
is_rho_reduction: true,
non_convergence: null,
lines_overloaded: ['LINE_1'],
load_shedding_details: [{ load_name: 'LOAD_X', voltage_level_id: 'VL_A', shedded_mw: 25.0 }],
};
(api.simulateManualAction as ReturnType<typeof vi.fn>).mockResolvedValueOnce(mockSimResult);
const mockSuperposition: CombinedAction = {
...baseCombined,
estimated_max_rho: 0.75,
estimated_max_rho_line: 'LINE_2',
};
(api.computeSuperposition as ReturnType<typeof vi.fn>).mockResolvedValueOnce(mockSuperposition);
const onUpdateCombinedEstimation = vi.fn();
const props = {
...defaultProps,
selectedActionId: actionId,
actions: {
[actionId]: {
description_unitaire: 'Load shedding on LOAD_X',
rho_before: [0.95],
rho_after: [0.70],
max_rho: 0.70,
max_rho_line: 'LINE_1',
is_rho_reduction: true,
non_convergence: null,
load_shedding_details: [{ load_name: 'LOAD_X', voltage_level_id: 'VL_A', shedded_mw: 42.5 }],
action_topology: { ...emptyTopo, loads_p: { LOAD_X: 0.0 } },
} as ActionDetail,
},
combinedActions: { [pairId]: baseCombined },
onUpdateCombinedEstimation,
};
render(<ActionFeed {...props} />);
// Change MW and re-simulate
const mwInput = screen.getByTestId(`edit-mw-${actionId}`);
fireEvent.change(mwInput, { target: { value: '25' } });
fireEvent.click(screen.getByTestId(`resimulate-${actionId}`));
await waitFor(() => {
expect(api.computeSuperposition).toHaveBeenCalledWith(
actionId, otherActionId, 'LINE_1'
);
});
await waitFor(() => {
expect(onUpdateCombinedEstimation).toHaveBeenCalledWith(
pairId,
{ estimated_max_rho: 0.75, estimated_max_rho_line: 'LINE_2' }
);
});
});
it('calls computeSuperposition for related combined pairs after PST tap re-simulation', async () => {
const pstActionId = 'pst_tap_ARKA_TD_661_inc2';
const pstPairId = `${pstActionId}+${otherActionId}`;
const mockSimResult = {
action_id: pstActionId,
description_unitaire: 'Variation PST ARKA TD 661',
rho_before: [1.1],
rho_after: [0.90],
max_rho: 0.90,
max_rho_line: 'LINE_A',
is_rho_reduction: true,
non_convergence: null,
lines_overloaded: ['LINE_1'],
pst_details: [{ pst_name: 'ARKA TD 661', tap_position: 30, low_tap: 8, high_tap: 31 }],
};
(api.simulateManualAction as ReturnType<typeof vi.fn>).mockResolvedValueOnce(mockSimResult);
const mockSuperposition: CombinedAction = {
...baseCombined,
action1_id: pstActionId,
estimated_max_rho: 0.80,
estimated_max_rho_line: 'LINE_B',
};
(api.computeSuperposition as ReturnType<typeof vi.fn>).mockResolvedValueOnce(mockSuperposition);
const onUpdateCombinedEstimation = vi.fn();
const pstCombined: CombinedAction = {
...baseCombined,
action1_id: pstActionId,
};
const props = {
...defaultProps,
selectedActionId: pstActionId,
actions: {
[pstActionId]: {
description_unitaire: 'Variation PST ARKA TD 661',
rho_before: [1.1],
rho_after: [0.95],
max_rho: 0.95,
max_rho_line: 'LINE_A',
is_rho_reduction: true,
action_topology: { ...emptyTopo, pst_tap: { 'ARKA TD 661': 29 } },
pst_details: [{ pst_name: 'ARKA TD 661', tap_position: 29, low_tap: 8, high_tap: 31 }],
} as ActionDetail,
},
combinedActions: { [pstPairId]: pstCombined },
onUpdateCombinedEstimation,
};
render(<ActionFeed {...props} />);
// Change tap and re-simulate
const tapInput = screen.getByTestId(`edit-tap-${pstActionId}`);
fireEvent.change(tapInput, { target: { value: '30' } });
fireEvent.click(screen.getByTestId(`resimulate-tap-${pstActionId}`));
await waitFor(() => {
expect(api.computeSuperposition).toHaveBeenCalledWith(
pstActionId, otherActionId, 'LINE_1'
);
});
await waitFor(() => {
expect(onUpdateCombinedEstimation).toHaveBeenCalledWith(
pstPairId,
{ estimated_max_rho: 0.80, estimated_max_rho_line: 'LINE_B' }
);
});
});
it('does not call computeSuperposition when no combined actions exist', async () => {
const mockSimResult = {
action_id: actionId,
description_unitaire: 'Load shedding on LOAD_X',
rho_before: [0.95],
rho_after: [0.60],
max_rho: 0.60,
max_rho_line: 'LINE_1',
is_rho_reduction: true,
non_convergence: null,
lines_overloaded: ['LINE_1'],
load_shedding_details: [{ load_name: 'LOAD_X', voltage_level_id: 'VL_A', shedded_mw: 25.0 }],
};
(api.simulateManualAction as ReturnType<typeof vi.fn>).mockResolvedValueOnce(mockSimResult);
const onUpdateCombinedEstimation = vi.fn();
const props = {
...defaultProps,
selectedActionId: actionId,
actions: {
[actionId]: {
description_unitaire: 'Load shedding on LOAD_X',
rho_before: [0.95],
rho_after: [0.70],
max_rho: 0.70,
max_rho_line: 'LINE_1',
is_rho_reduction: true,
non_convergence: null,
load_shedding_details: [{ load_name: 'LOAD_X', voltage_level_id: 'VL_A', shedded_mw: 42.5 }],
action_topology: { ...emptyTopo, loads_p: { LOAD_X: 0.0 } },
} as ActionDetail,
},
combinedActions: null,
onUpdateCombinedEstimation,
};
render(<ActionFeed {...props} />);
// Change MW and re-simulate
const mwInput = screen.getByTestId(`edit-mw-${actionId}`);
fireEvent.change(mwInput, { target: { value: '25' } });
fireEvent.click(screen.getByTestId(`resimulate-${actionId}`));
await waitFor(() => {
expect(api.simulateManualAction).toHaveBeenCalled();
});
// computeSuperposition should NOT be called
expect(api.computeSuperposition).not.toHaveBeenCalled();
expect(onUpdateCombinedEstimation).not.toHaveBeenCalled();
});
it('does not call onUpdateCombinedEstimation when computeSuperposition returns error', async () => {
const mockSimResult = {
action_id: actionId,
description_unitaire: 'Load shedding on LOAD_X',
rho_before: [0.95],
rho_after: [0.60],
max_rho: 0.60,
max_rho_line: 'LINE_1',
is_rho_reduction: true,
non_convergence: null,
lines_overloaded: ['LINE_1'],
load_shedding_details: [{ load_name: 'LOAD_X', voltage_level_id: 'VL_A', shedded_mw: 25.0 }],
};
(api.simulateManualAction as ReturnType<typeof vi.fn>).mockResolvedValueOnce(mockSimResult);
const errorResult: CombinedAction = {
...baseCombined,
error: 'Estimation failed',
};
(api.computeSuperposition as ReturnType<typeof vi.fn>).mockResolvedValueOnce(errorResult);
const onUpdateCombinedEstimation = vi.fn();
const props = {
...defaultProps,
selectedActionId: actionId,
actions: {
[actionId]: {
description_unitaire: 'Load shedding on LOAD_X',
rho_before: [0.95],
rho_after: [0.70],
max_rho: 0.70,
max_rho_line: 'LINE_1',
is_rho_reduction: true,
non_convergence: null,
load_shedding_details: [{ load_name: 'LOAD_X', voltage_level_id: 'VL_A', shedded_mw: 42.5 }],
action_topology: { ...emptyTopo, loads_p: { LOAD_X: 0.0 } },
} as ActionDetail,
},
combinedActions: { [pairId]: baseCombined },
onUpdateCombinedEstimation,
};
render(<ActionFeed {...props} />);
const mwInput = screen.getByTestId(`edit-mw-${actionId}`);
fireEvent.change(mwInput, { target: { value: '25' } });
fireEvent.click(screen.getByTestId(`resimulate-${actionId}`));
await waitFor(() => {
expect(api.computeSuperposition).toHaveBeenCalled();
});
// onUpdateCombinedEstimation should NOT be called because of error
expect(onUpdateCombinedEstimation).not.toHaveBeenCalled();
});
it('refreshes multiple combined pairs when action appears in several combinations', async () => {
const thirdActionId = 'line_disco_2';
const pairId2 = `${actionId}+${thirdActionId}`;
const mockSimResult = {
action_id: actionId,
description_unitaire: 'Load shedding on LOAD_X',
rho_before: [0.95],
rho_after: [0.60],
max_rho: 0.60,
max_rho_line: 'LINE_1',
is_rho_reduction: true,
non_convergence: null,
lines_overloaded: ['LINE_1'],
load_shedding_details: [{ load_name: 'LOAD_X', voltage_level_id: 'VL_A', shedded_mw: 25.0 }],
};
(api.simulateManualAction as ReturnType<typeof vi.fn>).mockResolvedValueOnce(mockSimResult);
const mockSuper1: CombinedAction = { ...baseCombined, estimated_max_rho: 0.75, estimated_max_rho_line: 'LINE_2' };
const mockSuper2: CombinedAction = { ...baseCombined, action2_id: thirdActionId, estimated_max_rho: 0.80, estimated_max_rho_line: 'LINE_3' };
(api.computeSuperposition as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce(mockSuper1)
.mockResolvedValueOnce(mockSuper2);
const onUpdateCombinedEstimation = vi.fn();
const combined2: CombinedAction = { ...baseCombined, action2_id: thirdActionId };
const props = {
...defaultProps,
selectedActionId: actionId,
actions: {
[actionId]: {
description_unitaire: 'Load shedding on LOAD_X',
rho_before: [0.95],
rho_after: [0.70],
max_rho: 0.70,
max_rho_line: 'LINE_1',
is_rho_reduction: true,
non_convergence: null,
load_shedding_details: [{ load_name: 'LOAD_X', voltage_level_id: 'VL_A', shedded_mw: 42.5 }],
action_topology: { ...emptyTopo, loads_p: { LOAD_X: 0.0 } },
} as ActionDetail,
},
combinedActions: { [pairId]: baseCombined, [pairId2]: combined2 },
onUpdateCombinedEstimation,
};
render(<ActionFeed {...props} />);
const mwInput = screen.getByTestId(`edit-mw-${actionId}`);
fireEvent.change(mwInput, { target: { value: '25' } });
fireEvent.click(screen.getByTestId(`resimulate-${actionId}`));
await waitFor(() => {
expect(api.computeSuperposition).toHaveBeenCalledTimes(2);
});
await waitFor(() => {
expect(onUpdateCombinedEstimation).toHaveBeenCalledTimes(2);
expect(onUpdateCombinedEstimation).toHaveBeenCalledWith(
pairId, { estimated_max_rho: 0.75, estimated_max_rho_line: 'LINE_2' }
);
expect(onUpdateCombinedEstimation).toHaveBeenCalledWith(
pairId2, { estimated_max_rho: 0.80, estimated_max_rho_line: 'LINE_3' }
);
});
});
});
describe('auto-scroll on selectedActionId change', () => {
// The action feed mounts a useEffect that scrolls the
// currently-viewed card into view whenever
// `selectedActionId` flips. This is what lets a
// double-click on a pin in the action overview center
// the matching card in the sidebar without the operator
// having to scroll.
const makeProps = (overrides: Partial<typeof defaultProps> = {}) => ({
...defaultProps,
actions: {
'act_a': {
description_unitaire: 'A',
rho_before: null,
rho_after: null,
max_rho: 0.5,
max_rho_line: 'LINE_1',
is_rho_reduction: true,
action_topology: emptyTopo,
},
'act_b': {
description_unitaire: 'B',
rho_before: null,
rho_after: null,
max_rho: 0.6,
max_rho_line: 'LINE_1',
is_rho_reduction: true,
action_topology: emptyTopo,
},
'act_c': {
description_unitaire: 'C',
rho_before: null,
rho_after: null,
max_rho: 0.7,
max_rho_line: 'LINE_1',
is_rho_reduction: true,
action_topology: emptyTopo,
},
},
...overrides,
});
beforeEach(() => {
// jsdom doesn't implement scrollIntoView; stub it
// so the effect can call it without throwing.
Element.prototype.scrollIntoView = vi.fn() as unknown as Element['scrollIntoView'];
});
it('calls scrollIntoView on the matching card when selectedActionId changes', async () => {
const { rerender } = render(<ActionFeed {...makeProps()} />);
// Switch to selecting act_b
rerender(<ActionFeed {...makeProps({ selectedActionId: 'act_b' })} />);
// Wait one rAF for the deferred scroll to fire
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
const card = screen.getByTestId('action-card-act_b');
expect(card.scrollIntoView).toHaveBeenCalledWith(
expect.objectContaining({ block: 'center' })
);
});
it('does NOT scroll when selectedActionId is null', async () => {
const scrollSpy = vi.spyOn(Element.prototype, 'scrollIntoView');
render(<ActionFeed {...makeProps()} />);
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
expect(scrollSpy).not.toHaveBeenCalled();
});
it('re-scrolls on each selectedActionId change', async () => {
const { rerender } = render(<ActionFeed {...makeProps({ selectedActionId: 'act_a' })} />);
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
const cardA = screen.getByTestId('action-card-act_a');
expect(cardA.scrollIntoView).toHaveBeenCalled();
rerender(<ActionFeed {...makeProps({ selectedActionId: 'act_c' })} />);
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
const cardC = screen.getByTestId('action-card-act_c');
expect(cardC.scrollIntoView).toHaveBeenCalled();
});
it('scrolls to card when scrollTarget is set (pin preview)', async () => {
const { rerender } = render(<ActionFeed {...makeProps()} />);
rerender(<ActionFeed {...makeProps({ scrollTarget: { id: 'act_b', seq: 1 } })} />);
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
const card = screen.getByTestId('action-card-act_b');
expect(card.scrollIntoView).toHaveBeenCalledWith(
expect.objectContaining({ block: 'center' })
);
});
it('re-scrolls to the same card when scrollTarget seq increments', async () => {
const { rerender } = render(
<ActionFeed {...makeProps({ scrollTarget: { id: 'act_a', seq: 1 } })} />
);
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
const card = screen.getByTestId('action-card-act_a');
expect(card.scrollIntoView).toHaveBeenCalledTimes(1);
rerender(
<ActionFeed {...makeProps({ scrollTarget: { id: 'act_a', seq: 2 } })} />
);
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
expect(card.scrollIntoView).toHaveBeenCalledTimes(2);
});
});
// Regression coverage for the "Add manual action → click card" pre-fetch
// flow. When the parent wires `onActionDiagramPrimed` + `voltageLevelsLength`,
// ActionFeed streams the combined `/api/simulate-and-variant-diagram`
// endpoint instead of firing `simulateManualAction` on its own: the
// metrics event feeds `onManualActionAdded` as before, AND the diagram
// event is pushed into the useDiagrams cache so the next click on the
// card paints the SVG without a second XHR. See
// docs/performance/history/combined-action-endpoint.md "Option B".
describe('Pre-fetch diagram on Add (option B)', () => {
const buildNdjsonResponse = (events: Array<Record<string, unknown>>) => {
const body = events.map(e => JSON.stringify(e)).join('\n') + '\n';
const bytes = new TextEncoder().encode(body);
let pushed = false;
return {
ok: true,
body: {
getReader: () => ({
read: async () => {
if (pushed) return { done: true, value: undefined } as const;
pushed = true;
return { done: false, value: bytes } as const;
},
}),
},
} as unknown as Response;
};
beforeEach(() => {
vi.clearAllMocks();
});
it('uses the streamed endpoint, calls onManualActionAdded with metrics, and primes the diagram', async () => {
const metricsEvent = {
type: 'metrics',
action_id: 'manual_act',
description_unitaire: 'Open LINE_Z',
rho_before: [0.95],
rho_after: [0.80],
max_rho: 0.80,
max_rho_line: 'LINE_1',
is_rho_reduction: true,
non_convergence: null,
lines_overloaded: ['LINE_1'],
};
const diagramEvent = {
type: 'diagram',
svg: '<svg>post-action</svg>',
metadata: '{}',
action_id: 'manual_act',
lf_converged: true,
lf_status: 'CONVERGED',
};
vi.mocked(api.getAvailableActions).mockResolvedValueOnce([]);
(api.simulateAndVariantDiagramStream as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce(buildNdjsonResponse([metricsEvent, diagramEvent]));
const onActionDiagramPrimed = vi.fn();
const onManualActionAdded = vi.fn();
render(
<ActionFeed
{...defaultProps}
onManualActionAdded={onManualActionAdded}
onActionDiagramPrimed={onActionDiagramPrimed}
voltageLevelsLength={42}
/>
);
fireEvent.click(screen.getByText('+ Manual Selection'));
const searchInput = await screen.findByPlaceholderText(/Search action/i);
fireEvent.change(searchInput, { target: { value: 'manual_act' } });
const manualOption = await screen.findByText(/Simulate manual ID:/);
fireEvent.click(manualOption);
await waitFor(() => {
expect(api.simulateAndVariantDiagramStream).toHaveBeenCalledTimes(1);
});
// Legacy single-shot endpoint must NOT be called on this path.
expect(api.simulateManualAction).not.toHaveBeenCalled();
await waitFor(() => {
expect(onManualActionAdded).toHaveBeenCalledTimes(1);
});
const [, detail, linesOvl] = (onManualActionAdded.mock.calls[0] as unknown) as [string, ActionDetail, string[]];
expect(detail.rho_after).toEqual([0.80]);
expect(detail.max_rho_line).toBe('LINE_1');
expect(linesOvl).toEqual(['LINE_1']);
// Diagram event was forwarded to the cache primer.
await waitFor(() => {
expect(onActionDiagramPrimed).toHaveBeenCalledTimes(1);
});
expect(onActionDiagramPrimed.mock.calls[0][0]).toBe('manual_act');
expect((onActionDiagramPrimed.mock.calls[0][1] as { svg: string }).svg).toBe('<svg>post-action</svg>');
expect(onActionDiagramPrimed.mock.calls[0][2]).toBe(42);
});
it('falls back to simulateManualAction when the primer prop is not wired', async () => {
// Without `onActionDiagramPrimed`, the legacy single-shot path
// is preserved — this is what older tests and the existing
// backward-compatible callers rely on.
const mockResult = {
action_id: 'manual_act',
description_unitaire: 'Open LINE_Z',
rho_before: [0.95],
rho_after: [0.80],
max_rho: 0.80,
max_rho_line: 'LINE_1',
is_rho_reduction: true,
non_convergence: null,
lines_overloaded: ['LINE_1'],
};
vi.mocked(api.getAvailableActions).mockResolvedValueOnce([]);
(api.simulateManualAction as ReturnType<typeof vi.fn>).mockResolvedValueOnce(mockResult);
const onManualActionAdded = vi.fn();
render(
<ActionFeed
{...defaultProps}
onManualActionAdded={onManualActionAdded}
// intentionally no onActionDiagramPrimed
/>
);
fireEvent.click(screen.getByText('+ Manual Selection'));
const searchInput = await screen.findByPlaceholderText(/Search action/i);
fireEvent.change(searchInput, { target: { value: 'manual_act' } });
const manualOption = await screen.findByText(/Simulate manual ID:/);
fireEvent.click(manualOption);
await waitFor(() => {
expect(api.simulateManualAction).toHaveBeenCalledTimes(1);
});
expect(api.simulateAndVariantDiagramStream).not.toHaveBeenCalled();
});
});
describe('action-type filter — overview cards (overviewFilters.actionType)', () => {
const makeFilter = (actionType: 'all' | 'disco' | 'reco' | 'ls' | 'rc' | 'open' | 'close' | 'pst') => ({
categories: { green: true, orange: true, red: true, grey: true },
threshold: 1.5,
showUnsimulated: false,
actionType,
});
const actionsMix: Record<string, ActionDetail> = {
disco_LINE_A: {
description_unitaire: 'Ouverture de la ligne LINE_A',
rho_before: [1.1],
rho_after: [0.9],
max_rho: 0.9,
max_rho_line: 'LINE_A',
is_rho_reduction: true,
action_topology: { lines_ex_bus: {}, lines_or_bus: {}, gens_bus: {}, loads_bus: {} },
},
reco_LINE_B: {
description_unitaire: 'Fermeture de la ligne LINE_B',
rho_before: [1.2],
rho_after: [0.95],
max_rho: 0.95,
max_rho_line: 'LINE_B',
is_rho_reduction: true,
action_topology: { lines_ex_bus: {}, lines_or_bus: {}, gens_bus: {}, loads_bus: {} },
},
};
it('hides cards of the other bucket in the Selected list when overviewFilters.actionType is "disco"', () => {
render(<ActionFeed
{...defaultProps}
actions={actionsMix}
selectedActionIds={new Set(['disco_LINE_A', 'reco_LINE_B'])}
overviewFilters={makeFilter('disco')}
/>);
expect(screen.getByTestId('action-card-disco_LINE_A')).toBeInTheDocument();
expect(screen.queryByTestId('action-card-reco_LINE_B')).not.toBeInTheDocument();
});
it('shows every card when overviewFilters.actionType is "all"', () => {
render(<ActionFeed
{...defaultProps}
actions={actionsMix}
selectedActionIds={new Set(['disco_LINE_A', 'reco_LINE_B'])}
overviewFilters={makeFilter('all')}
/>);
expect(screen.getByTestId('action-card-disco_LINE_A')).toBeInTheDocument();
expect(screen.getByTestId('action-card-reco_LINE_B')).toBeInTheDocument();
});
it('shows every card when overviewFilters is undefined', () => {
render(<ActionFeed
{...defaultProps}
actions={actionsMix}
selectedActionIds={new Set(['disco_LINE_A', 'reco_LINE_B'])}
/>);
expect(screen.getByTestId('action-card-disco_LINE_A')).toBeInTheDocument();
expect(screen.getByTestId('action-card-reco_LINE_B')).toBeInTheDocument();
});
});
describe('action-type filter — manual-selection ring (local state, independent of overview)', () => {
it('manual-selection ring toggle does NOT call onOverviewFiltersChange', async () => {
const onOverviewFiltersChange = vi.fn();
vi.mocked(api.getAvailableActions).mockResolvedValueOnce([]);
render(<ActionFeed
{...defaultProps}
overviewFilters={{ categories: { green: true, orange: true, red: true, grey: true }, threshold: 1.5, showUnsimulated: false, actionType: 'all', showCombinedOnly: false }}
onOverviewFiltersChange={onOverviewFiltersChange}
/>);
fireEvent.click(screen.getByText('+ Manual Selection'));
const discoChip = await screen.findByTestId('sidebar-filter-type-disco');
fireEvent.click(discoChip);
// Local dropdown state — does NOT bubble up to the overview filter.
expect(onOverviewFiltersChange).not.toHaveBeenCalled();
});
it('marks the active action-type toggle with aria-pressed="true" after clicking LS', async () => {
vi.mocked(api.getAvailableActions).mockResolvedValueOnce([]);
render(<ActionFeed {...defaultProps} />);
fireEvent.click(screen.getByText('+ Manual Selection'));
const lsChip = await screen.findByTestId('sidebar-filter-type-ls');
fireEvent.click(lsChip);
expect(lsChip.getAttribute('aria-pressed')).toBe('true');
expect(screen.getByTestId('sidebar-filter-type-disco').getAttribute('aria-pressed')).toBe('false');
});
it('filters the Scored Actions table after clicking DISCO chip', async () => {
const actionScores = {
line_disconnection: { scores: { disco_LINE_A: 10 } },
line_reconnection: { scores: { reco_LINE_B: 20 } },
};
vi.mocked(api.getAvailableActions).mockResolvedValueOnce([]);
render(<ActionFeed {...defaultProps} actionScores={actionScores} />);
fireEvent.click(screen.getByText('+ Manual Selection'));
const discoChip = await screen.findByTestId('sidebar-filter-type-disco');
fireEvent.click(discoChip);
expect(await screen.findByText('disco_LINE_A')).toBeInTheDocument();
expect(screen.queryByText('reco_LINE_B')).not.toBeInTheDocument();
});
it('clicking a dropdown chip does not throw', async () => {
vi.mocked(api.getAvailableActions).mockResolvedValueOnce([]);
render(<ActionFeed {...defaultProps} />);
fireEvent.click(screen.getByText('+ Manual Selection'));
const discoChip = await screen.findByTestId('sidebar-filter-type-disco');
expect(() => fireEvent.click(discoChip)).not.toThrow();
});
});
describe('AdditionalLinesPicker placement', () => {
// Product rule: the picker sits directly above the
// "Analyze & Suggest" button so the operator can configure
// extra cuts where the analysis is launched. It must
// disappear the moment analysis is in flight or results are
// pending — same gate as the button itself.
// D4 consolidation: the picker wiring is one grouped `additionalLines`
// object on ActionFeed instead of four flat props.
const pickerProps = {
additionalLines: {
branches: ['LINE_A', 'LINE_B', 'LINE_C'],
additionalLinesToCut: new Set<string>(),
onToggleAdditionalLineToCut: vi.fn(),
n1Overloads: ['LINE_C'],
},
};
it('renders the picker alongside Analyze & Suggest in the empty state', () => {
render(<ActionFeed {...defaultProps} {...pickerProps} canRunAnalysis />);
expect(screen.getByTestId('additional-lines-picker')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Analyze & Suggest/ })).toBeInTheDocument();
});
it('hides the picker while analysis is running', () => {
render(<ActionFeed {...defaultProps} {...pickerProps} analysisLoading />);
expect(screen.queryByTestId('additional-lines-picker')).not.toBeInTheDocument();
expect(screen.getByText(/Analyzing/)).toBeInTheDocument();
});
it('hides the picker when a pending result is awaiting display', () => {
const pending: AnalysisResult = {
actions: {}, action_scores: {}, lines_overloaded: ['LINE_C'],
pdf_url: null, pdf_path: null, message: '', dc_fallback: false,
} as unknown as AnalysisResult;
render(
<ActionFeed
{...defaultProps}
{...pickerProps}
pendingAnalysisResult={pending}
/>,
);
expect(screen.queryByTestId('additional-lines-picker')).not.toBeInTheDocument();
expect(screen.getByText(/Display .* prioritized actions/)).toBeInTheDocument();
});
it('hides the picker when the picker props are not wired', () => {
// Backwards-compat: ActionFeed must render correctly when
// the parent forgets to pass branches / extras (e.g. an
// unrelated test that doesn't care about the picker).
render(<ActionFeed {...defaultProps} canRunAnalysis />);
expect(screen.queryByTestId('additional-lines-picker')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /Analyze & Suggest/ })).toBeInTheDocument();
});
});
describe('action-type overview filter', () => {
const DEFAULT_FILTERS = {
categories: { green: true, orange: true, red: true, grey: true },
threshold: 1.5,
showUnsimulated: false,
actionType: 'all' as const,
showCombinedOnly: false,
};
// A reco action whose id starts with `reco_` but whose
// description carries no `fermeture` marker — exactly the
// shape that used to vanish under the RECO filter because
// `classifyActionType` never inspects the id for disco / reco.
const recoDetail: ActionDetail = {
description_unitaire: 'Reconnect GEN.PY762',
rho_before: [1.05],
rho_after: [0.93],
max_rho: 0.93,
max_rho_line: 'LINE_1',
is_rho_reduction: true,
action_topology: emptyTopo,
};
it('keeps reco cards visible under the RECO filter using the recommender score type', () => {
render(
<ActionFeed
{...defaultProps}
actions={{ 'reco_GEN.PY762': recoDetail }}
actionScores={{ line_reconnection: { scores: { 'reco_GEN.PY762': 0.04 } } }}
overviewFilters={{ ...DEFAULT_FILTERS, actionType: 'reco' }}
onOverviewFiltersChange={vi.fn()}
/>,
);
expect(screen.getByTestId('action-card-reco_GEN.PY762')).toBeInTheDocument();
expect(screen.queryByTestId('overview-filter-hint')).not.toBeInTheDocument();
});
it('hides reco cards when a non-matching type filter (DISCO) is active', () => {
render(
<ActionFeed
{...defaultProps}
actions={{ 'reco_GEN.PY762': recoDetail }}
actionScores={{ line_reconnection: { scores: { 'reco_GEN.PY762': 0.04 } } }}
overviewFilters={{ ...DEFAULT_FILTERS, actionType: 'disco' }}
onOverviewFiltersChange={vi.fn()}
/>,
);
expect(screen.queryByTestId('action-card-reco_GEN.PY762')).not.toBeInTheDocument();
expect(screen.getByTestId('overview-filter-hint')).toBeInTheDocument();
});
it('suppresses the Analyze & Suggest button and shows the no-match notice when the filter hides every action', () => {
// Product rule (PR — fix-empty-filter-message): when the
// overview filter hides every otherwise-eligible action,
// the Analyze & Suggest button must NOT reappear — only
// clearing the filter (or pressing Clear) should bring it
// back. The notice tells the operator why their feed
// is empty.
render(
<ActionFeed
{...defaultProps}
actions={{ 'reco_GEN.PY762': recoDetail }}
actionScores={{ line_reconnection: { scores: { 'reco_GEN.PY762': 0.04 } } }}
overviewFilters={{ ...DEFAULT_FILTERS, actionType: 'disco' }}
onOverviewFiltersChange={vi.fn()}
canRunAnalysis
/>,
);
expect(screen.queryByRole('button', { name: /Analyze & Suggest/ })).not.toBeInTheDocument();
expect(screen.getByTestId('no-actions-match-filter')).toBeInTheDocument();
expect(screen.getByText(/No actions match the active filter/)).toBeInTheDocument();
});
it('reshows the Analyze & Suggest button once the filter is cleared', () => {
// Same fixture as above but with `actionType: 'all'` —
// the action matches the filter, so the feed renders the
// card AND the Analyze & Suggest slot stays hidden because
// prioritizedEntries is non-empty. Clearing the type
// filter alone is enough to restore the slot when no card
// is left visible (verified by removing the action below).
const { rerender } = render(
<ActionFeed
{...defaultProps}
actions={{ 'reco_GEN.PY762': recoDetail }}
actionScores={{ line_reconnection: { scores: { 'reco_GEN.PY762': 0.04 } } }}
overviewFilters={{ ...DEFAULT_FILTERS, actionType: 'disco' }}
onOverviewFiltersChange={vi.fn()}
canRunAnalysis
/>,
);
expect(screen.queryByRole('button', { name: /Analyze & Suggest/ })).not.toBeInTheDocument();
rerender(
<ActionFeed
{...defaultProps}
actions={{}}
actionScores={{}}
overviewFilters={{ ...DEFAULT_FILTERS, actionType: 'all' }}
onOverviewFiltersChange={vi.fn()}
canRunAnalysis
/>,
);
expect(screen.getByRole('button', { name: /Analyze & Suggest/ })).toBeInTheDocument();
expect(screen.queryByTestId('no-actions-match-filter')).not.toBeInTheDocument();
});
});
describe('recommendation-model selector + Clear', () => {
// The model dropdown lives above "Analyze & Suggest" (a mirror of
// the Settings → Recommender selector) so the operator can swap
// model and re-run without opening Settings. After a run, the
// model that produced the suggestions is reminded below the
// Suggested Actions tab header alongside a danger-coloured Clear
// button.
// D4 consolidation: the model dropdown wiring is one grouped
// `modelSelector` object on ActionFeed instead of flat props.
const modelProps = {
modelSelector: {
recommenderModel: 'expert',
setRecommenderModel: vi.fn(),
availableModels: [
{ name: 'expert', label: 'Expert system', requires_overflow_graph: true, is_default: true, params: [] },
{ name: 'random_overflow', label: 'Random (post overflow analysis)', requires_overflow_graph: true, is_default: false, params: [] },
],
},
};
const recAction = {
description_unitaire: 'Disco LINE_X',
action_topology: emptyTopo,
is_manual: false,
max_rho: 0.8,
} as unknown as ActionDetail;
beforeEach(() => {
interactionLogger.clear();
});
it('renders the recommendation-model dropdown above Analyze & Suggest', () => {
render(<ActionFeed {...defaultProps} {...modelProps} canRunAnalysis />);
const select = screen.getByLabelText('Model:') as HTMLSelectElement;
expect(select).toBeInTheDocument();
expect(select.value).toBe('expert');
expect(screen.getByRole('option', { name: 'Random (post overflow analysis)' })).toBeInTheDocument();
});
it('changing the model fires setRecommenderModel and logs recommender_model_changed', () => {
const setRecommenderModel = vi.fn();
render(
<ActionFeed
{...defaultProps}
modelSelector={{ ...modelProps.modelSelector, setRecommenderModel }}
canRunAnalysis
/>,
);
fireEvent.change(screen.getByLabelText('Model:'), { target: { value: 'random_overflow' } });
expect(setRecommenderModel).toHaveBeenCalledWith('random_overflow');
const evt = interactionLogger.getLog().find(e => e.type === 'recommender_model_changed');
expect(evt).toBeTruthy();
expect(evt!.details).toEqual({ model: 'random_overflow', source: 'action_feed' });
});
it('omits the model dropdown when setRecommenderModel is not wired', () => {
// Backwards-compat: ActionFeed must render without the model
// selector when the parent doesn't pass the recommender props.
render(<ActionFeed {...defaultProps} canRunAnalysis />);
expect(screen.queryByLabelText('Model:')).not.toBeInTheDocument();
});
it('shows the "Suggestions produced by <model>" reminder with the active model label', () => {
render(
<ActionFeed
{...defaultProps}
actions={{ rec_1: recAction }}
modelSelector={{ activeModelLabel: 'Expert system' }}
onClearSuggested={vi.fn()}
/>,
);
expect(screen.getByTestId('active-model-reminder')).toHaveTextContent(
'Suggestions produced by Expert system',
);
});
it('hides the active-model reminder when there are no suggested entries', () => {
render(
<ActionFeed
{...defaultProps}
actions={{}}
modelSelector={{ activeModelLabel: 'Expert system' }}
onClearSuggested={vi.fn()}
/>,
);
expect(screen.queryByTestId('active-model-reminder')).not.toBeInTheDocument();
});
it('shows the wall-clock total next to the active-model label with a full breakdown tooltip', () => {
render(
<ActionFeed
{...defaultProps}
actions={{ rec_1: recAction }}
modelSelector={{ activeModelLabel: 'Expert system' }}
timing={{
step1Time: 1.0,
overflowGraphTime: 2.5,
actionPredictionTime: 1.25,
assessmentTime: 0.4,
enrichmentTime: 0.1,
wallClockTime: 5.5,
}}
/>,
);
const total = screen.getByTestId('execution-time-total');
// Wall-clock wins over the backend-sum (5.25s).
expect(total).toHaveTextContent(/5\.50s/);
const tooltip = total.getAttribute('title') ?? '';
expect(tooltip).toMatch(/Step 1.*1\.00s/);
expect(tooltip).toMatch(/Overflow analysis:\s*2\.50s/);
expect(tooltip).toMatch(/Action prediction:\s*1\.25s/);
expect(tooltip).toMatch(/Action assessment:\s*0\.40s/);
expect(tooltip).toMatch(/Enrichment.*0\.10s/);
// Residual = wall-clock - backend sum = 5.50 - 5.25 = 0.25s.
expect(tooltip).toMatch(/Other.*0\.25s/);
expect(tooltip).toMatch(/Total \(wall-clock.*5\.50s/);
});
it('falls back to the backend-sum total when wall-clock is not reported', () => {
render(
<ActionFeed
{...defaultProps}
actions={{ rec_1: recAction }}
modelSelector={{ activeModelLabel: 'Random' }}
timing={{
overflowGraphTime: null,
actionPredictionTime: 0.8,
assessmentTime: 0.2,
}}
/>,
);
const total = screen.getByTestId('execution-time-total');
// backend-sum = 0.8 + 0.2 = 1.00s
expect(total).toHaveTextContent(/1\.00s/);
const tooltip = total.getAttribute('title') ?? '';
expect(tooltip).toMatch(/Action prediction/);
expect(tooltip).toMatch(/Action assessment/);
expect(tooltip).not.toMatch(/Overflow analysis/);
});
it('hides the execution-time total when no timings are reported', () => {
render(
<ActionFeed
{...defaultProps}
actions={{ rec_1: recAction }}
modelSelector={{ activeModelLabel: 'Expert system' }}
/>,
);
expect(screen.queryByTestId('execution-time-total')).not.toBeInTheDocument();
});
it('Clear button calls onClearSuggested', () => {
const onClearSuggested = vi.fn();
render(
<ActionFeed
{...defaultProps}
actions={{ rec_1: recAction }}
modelSelector={{ activeModelLabel: 'Expert system' }}
onClearSuggested={onClearSuggested}
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'Clear' }));
expect(onClearSuggested).toHaveBeenCalledTimes(1);
});
it('hides the Analyze & Suggest slot while suggested entries are displayed', () => {
render(<ActionFeed {...defaultProps} actions={{ rec_1: recAction }} canRunAnalysis />);
expect(screen.queryByRole('button', { name: /Analyze & Suggest/ })).not.toBeInTheDocument();
});
it('reshows the Analyze & Suggest slot when only rejected actions remain (post-Clear)', () => {
// After Clear, the operator's kept rejected action stays in
// result.actions with is_manual=false. The analysis-trigger
// slot must still reappear because the Suggested feed
// (prioritizedEntries) is empty — gating on prioritizedEntries
// rather than "any non-manual action" is what fixes this.
render(
<ActionFeed
{...defaultProps}
actions={{ rej_1: recAction }}
rejectedActionIds={new Set(['rej_1'])}
canRunAnalysis
/>,
);
expect(screen.getByRole('button', { name: /Analyze & Suggest/ })).toBeInTheDocument();
});
});
// D4 consolidation: prove the three grouped ActionFeed props
// (additionalLines / modelSelector / timing) are unpacked and forwarded
// when passed as a whole object, and that each is safely OPTIONAL. These
// pass the grouped object DIRECTLY so they lock the object-prop contract.
describe('grouped additionalLines / modelSelector / timing props (D4 consolidation)', () => {
const recAction = {
description_unitaire: 'Disco LINE_X',
action_topology: emptyTopo,
is_manual: false,
max_rho: 0.8,
} as unknown as ActionDetail;
it('threads a grouped additionalLines object through to the picker', () => {
render(
<ActionFeed
{...defaultProps}
additionalLines={{
branches: ['LINE_A', 'LINE_B'],
additionalLinesToCut: new Set<string>(),
onToggleAdditionalLineToCut: vi.fn(),
n1Overloads: ['LINE_B'],
}}
canRunAnalysis
/>,
);
expect(screen.getByTestId('additional-lines-picker')).toBeInTheDocument();
});
it('omits the picker when the additionalLines group is absent', () => {
render(<ActionFeed {...defaultProps} canRunAnalysis />);
expect(screen.queryByTestId('additional-lines-picker')).not.toBeInTheDocument();
});
it('threads a grouped modelSelector object through to the Model dropdown', () => {
const setRecommenderModel = vi.fn();
render(
<ActionFeed
{...defaultProps}
modelSelector={{
recommenderModel: 'expert',
setRecommenderModel,
availableModels: [
{ name: 'expert', label: 'Expert system', requires_overflow_graph: true, is_default: true, params: [] },
{ name: 'random_overflow', label: 'Random (post overflow analysis)', requires_overflow_graph: true, is_default: false, params: [] },
],
}}
canRunAnalysis
/>,
);
const select = screen.getByLabelText('Model:') as HTMLSelectElement;
expect(select.value).toBe('expert');
fireEvent.change(select, { target: { value: 'random_overflow' } });
expect(setRecommenderModel).toHaveBeenCalledWith('random_overflow');
});
it('omits the Model dropdown when the modelSelector group is absent', () => {
render(<ActionFeed {...defaultProps} canRunAnalysis />);
expect(screen.queryByLabelText('Model:')).not.toBeInTheDocument();
});
it('threads a grouped timing object through to the execution-time total', () => {
render(
<ActionFeed
{...defaultProps}
actions={{ rec_1: recAction }}
modelSelector={{ activeModelLabel: 'Expert system' }}
timing={{
step1Time: 1.0,
overflowGraphTime: 2.5,
actionPredictionTime: 1.25,
assessmentTime: 0.4,
enrichmentTime: 0.1,
wallClockTime: 5.5,
}}
/>,
);
expect(screen.getByTestId('execution-time-total')).toHaveTextContent(/5\.50s/);
});
it('omits the execution-time total when the timing group is absent', () => {
render(
<ActionFeed
{...defaultProps}
actions={{ rec_1: recAction }}
modelSelector={{ activeModelLabel: 'Expert system' }}
/>,
);
expect(screen.queryByTestId('execution-time-total')).not.toBeInTheDocument();
});
});
});