// 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 }) => (
), })); vi.mock('./components/OverloadPanel', () => ({ default: () =>
, })); 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: '', metadata: null }), getContingencyDiagram: vi.fn().mockResolvedValue({ svg: '', metadata: null, lines_overloaded: [] }), getActionVariantDiagram: vi.fn().mockResolvedValue({ 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 { 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(); 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 ) and the // recommendation-model