Co-Study4Grid / frontend /src /App.actionOrigin.test.tsx
github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
10.9 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 React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor, act, cleanup } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom/vitest';
import App from './App';
// End-to-end coverage for action-card provenance ("origin"). Unlike the
// other App.*.test.tsx files, this one does NOT mock ActionFeed — it
// renders the real ActionFeed + ActionCard so the "Source" row in the
// unfolded card can be asserted. The whole wiring chain is exercised:
// useAnalysis / useActions stamp `origin` → App's `result` → ActionFeed
// `actions` + `availableModels` props → ActionCard "Source" row.
// ===== Mocks (everything EXCEPT ActionFeed) =====
vi.mock('./components/VisualizationPanel', () => ({
default: (props: { activeTab: string }) => (
<div data-testid="visualization-panel" data-active-tab={props.activeTab} />
),
}));
vi.mock('./components/OverloadPanel', () => ({
default: () => <div data-testid="overload-panel" />,
}));
vi.mock('./hooks/usePanZoom', () => ({
usePanZoom: () => ({ viewBox: null, setViewBox: vi.fn() }),
}));
vi.mock('./utils/svgUtils', () => ({
processSvg: (svg: string) => ({ svg, viewBox: { x: 0, y: 0, w: 100, h: 100 } }),
buildMetadataIndex: () => null,
applyOverloadedHighlights: vi.fn(),
applyDeltaVisuals: vi.fn(),
applyActionTargetHighlights: vi.fn(),
applyContingencyHighlight: vi.fn(),
getIdMap: () => new Map(),
invalidateIdMapCache: vi.fn(),
isCouplingAction: vi.fn(() => false),
attachVlInteractions: vi.fn(() => () => {}),
// ActionFeed filters its card list through this — always pass so the
// origin assertions aren't masked by the severity/threshold gate.
actionPassesOverviewFilter: vi.fn(() => true),
getActionTargetVoltageLevels: vi.fn(() => []),
getActionTargetLines: vi.fn(() => []),
}));
const mockApi = vi.hoisted(() => ({
updateConfig: vi.fn().mockResolvedValue({ monitored_lines_count: 10, total_lines_count: 10 }),
getBranches: vi.fn().mockResolvedValue({ branches: ['BRANCH_A', 'BRANCH_B'], name_map: {} }),
getVoltageLevels: vi.fn().mockResolvedValue({ voltage_levels: ['VL1', 'VL2'], name_map: {} }),
getNominalVoltages: vi.fn().mockResolvedValue({ mapping: {}, unique_kv: [63, 225] }),
getVoltageLevelSubstations: vi.fn().mockResolvedValue({ mapping: {} }),
getNetworkDiagram: vi.fn().mockResolvedValue({ svg: '<svg></svg>', metadata: null }),
getContingencyDiagram: vi.fn().mockResolvedValue({ svg: '<svg></svg>', metadata: null, lines_overloaded: [] }),
getActionVariantDiagram: vi.fn().mockResolvedValue({ svg: '<svg></svg>', metadata: null }),
runAnalysisStep1: vi.fn().mockResolvedValue({ can_proceed: true, lines_overloaded: ['LINE_OL1'] }),
runAnalysisStep2Stream: vi.fn(),
simulateAndVariantDiagramStream: vi.fn(),
getAvailableActions: vi.fn().mockResolvedValue([]),
getModels: vi.fn().mockResolvedValue({
models: [
{ name: 'expert', label: 'Expert system', requires_overflow_graph: true, is_default: true, params: [] },
],
}),
setRecommenderModel: vi.fn().mockResolvedValue({ status: 'success', active_model: 'expert', compute_overflow_graph: true }),
pickPath: vi.fn(),
getUserConfig: vi.fn().mockResolvedValue({
network_path: '/home/user/data/grid.xiidm',
action_file_path: '/home/user/data/actions.json',
}),
getConfigFilePath: vi.fn().mockResolvedValue('/home/user/data/config.json'),
saveUserConfig: vi.fn().mockResolvedValue({}),
setConfigFilePath: vi.fn().mockResolvedValue({ config_file_path: '/home/user/data/config.json', config: {} }),
}));
vi.mock('./api', () => ({ api: mockApi }));
// ===== Helpers =====
/** Build a one-shot ReadableStream from a list of NDJSON event objects. */
function ndjsonStream(events: object[]): ReadableStream<Uint8Array> {
const encoder = new TextEncoder();
return new ReadableStream({
start(controller) {
for (const e of events) controller.enqueue(encoder.encode(JSON.stringify(e) + '\n'));
controller.close();
},
});
}
async function renderAndLoadStudy() {
render(<App />);
await userEvent.click(screen.getByText('🔄 Load Study'));
await waitFor(() => {
expect(screen.getByText('⚡ Select Contingency')).toBeInTheDocument();
}, { timeout: 5000 });
}
async function selectBranch(branchName: string) {
// Two comboboxes exist now that the real ActionFeed renders: the
// contingency react-select (an <input role="combobox">) and the
// recommendation-model <select> above Analyze & Suggest. Target the
// react-select input.
const comboboxes = screen.getAllByRole('combobox');
const combobox = comboboxes.find(el => el.tagName === 'INPUT') ?? comboboxes[0];
await act(async () => {
await userEvent.click(combobox);
await userEvent.type(combobox, branchName);
await userEvent.keyboard('{Enter}');
});
const trigger = await screen.findByRole('button', { name: /Trigger/ });
await act(async () => { await userEvent.click(trigger); });
await waitFor(() => {
expect(mockApi.getContingencyDiagram).toHaveBeenCalledWith([branchName]);
});
}
describe('App — action-card origin (provenance) end-to-end', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
vi.unstubAllGlobals();
// Restore default resolved values cleared by clearAllMocks.
mockApi.updateConfig.mockResolvedValue({ monitored_lines_count: 10, total_lines_count: 10 });
mockApi.getBranches.mockResolvedValue({ branches: ['BRANCH_A', 'BRANCH_B'], name_map: {} });
mockApi.getVoltageLevels.mockResolvedValue({ voltage_levels: ['VL1', 'VL2'], name_map: {} });
mockApi.getNominalVoltages.mockResolvedValue({ mapping: {}, unique_kv: [63, 225] });
mockApi.getVoltageLevelSubstations.mockResolvedValue({ mapping: {} });
mockApi.getNetworkDiagram.mockResolvedValue({ svg: '<svg></svg>', metadata: null });
mockApi.getContingencyDiagram.mockResolvedValue({ svg: '<svg></svg>', metadata: null, lines_overloaded: [] });
mockApi.getActionVariantDiagram.mockResolvedValue({ svg: '<svg></svg>', metadata: null });
mockApi.runAnalysisStep1.mockResolvedValue({ can_proceed: true, lines_overloaded: ['LINE_OL1'] });
mockApi.getAvailableActions.mockResolvedValue([]);
mockApi.getModels.mockResolvedValue({
models: [
{ name: 'expert', label: 'Expert system', requires_overflow_graph: true, is_default: true, params: [] },
],
});
mockApi.getUserConfig.mockResolvedValue({
network_path: '/home/user/data/grid.xiidm',
action_file_path: '/home/user/data/actions.json',
});
mockApi.getConfigFilePath.mockResolvedValue('/home/user/data/config.json');
mockApi.setConfigFilePath.mockResolvedValue({ config_file_path: '/home/user/data/config.json', config: {} });
});
afterEach(() => cleanup());
it('stamps a recommender-produced action with the model origin and shows it in the unfolded card', async () => {
await renderAndLoadStudy();
await selectBranch('BRANCH_A');
// Step-2 stream: a single recommender action + the active_model echo.
mockApi.runAnalysisStep2Stream.mockResolvedValue({
ok: true,
body: ndjsonStream([
{
type: 'result',
actions: {
ACT_RECO: {
description_unitaire: 'Disconnect LINE_X',
rho_before: [1.05], rho_after: [0.85],
max_rho: 0.85, max_rho_line: 'LINE_OL1', is_rho_reduction: true,
},
},
active_model: 'expert',
lines_overloaded: ['LINE_OL1'],
message: 'done', dc_fallback: false,
},
]),
});
await act(async () => { await userEvent.click(screen.getByText('🔍 Analyze & Suggest')); });
const displayBtn = await screen.findByText(/Display.*prioritized actions/, {}, { timeout: 3000 });
await act(async () => { await userEvent.click(displayBtn); });
// The recommender action card is now in the Suggested feed. Click it
// to unfold the progressive-disclosure section.
const card = await screen.findByTestId('action-card-ACT_RECO');
await act(async () => { await userEvent.click(card); });
// The "Source" row resolves the `active_model` id ("expert") to its
// human label via availableModels (GET /api/models).
const originRow = await screen.findByTestId('action-card-ACT_RECO-origin');
expect(originRow).toHaveTextContent('Source: Expert system');
});
it('stamps a manually-simulated action with origin "user" and shows it in the unfolded card', async () => {
await renderAndLoadStudy();
await selectBranch('BRANCH_A');
// The manual-simulation flow at the App level goes through the
// streaming primer (onActionDiagramPrimed is wired), so mock
// simulateAndVariantDiagramStream with a metrics + diagram event.
mockApi.simulateAndVariantDiagramStream.mockResolvedValue({
body: ndjsonStream([
{
type: 'metrics',
description_unitaire: 'Manually chosen action',
rho_before: [1.1], rho_after: [0.9],
max_rho: 0.9, max_rho_line: 'LINE_OL1', is_rho_reduction: true,
is_islanded: false, n_components: 1, disconnected_mw: 0,
non_convergence: null, lines_overloaded: ['LINE_OL1'],
lines_overloaded_after: [], load_shedding_details: [],
curtailment_details: [], pst_details: [],
action_topology: { lines_ex_bus: {}, lines_or_bus: {}, gens_bus: {}, loads_bus: {} },
is_estimated: false,
},
{ type: 'diagram', svg: '<svg></svg>', metadata: null },
]),
});
// Open the manual-selection search and simulate a free-text action id.
await act(async () => { await userEvent.click(screen.getByText('+ Manual Selection')); });
const search = await screen.findByPlaceholderText(/Search action/);
await act(async () => { await userEvent.type(search, 'my_manual_action'); });
await act(async () => { await userEvent.click(screen.getByText(/Simulate manual ID/)); });
// handleManualActionAdded auto-selects the new action, so its card
// is already unfolded — the "Source" row reads the literal "user".
const originRow = await screen.findByTestId('action-card-my_manual_action-origin', {}, { timeout: 3000 });
expect(originRow).toHaveTextContent('Source: Manual simulation (user)');
});
});