// Copyright (c) 2025-2026, RTE (https://www.rte-france.com) // This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. // If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, // you can obtain one at http://mozilla.org/MPL/2.0/. // SPDX-License-Identifier: MPL-2.0 // This file is part of Co-Study4Grid a Power Grid Study tool Assistant Interface to help solve contigencies for a grid state under study. import { describe, it, expect, vi } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; import '@testing-library/jest-dom/vitest'; vi.mock('../utils/svgUtils', () => ({ getActionTargetVoltageLevels: vi.fn(() => []), getActionTargetLines: vi.fn(() => []), isCouplingAction: vi.fn(() => false), })); import ActionCard from './ActionCard'; import type { ActionDetail } from '../types'; describe('ActionCard', () => { const emptyTopo = { lines_ex_bus: {}, lines_or_bus: {}, gens_bus: {}, loads_bus: {} }; const baseDetails: ActionDetail = { description_unitaire: 'Open line L1', rho_before: [1.05], rho_after: [0.85], max_rho: 0.85, max_rho_line: 'LINE_A', is_rho_reduction: true, action_topology: emptyTopo, }; const defaultProps = { id: 'act_1', details: baseDetails, index: 0, isViewing: false, isSelected: false, isRejected: false, linesOverloaded: ['LINE_A'], monitoringFactor: 0.95, nodesByEquipmentId: null, edgesByEquipmentId: null, cardEditMw: {} as Record, cardEditTap: {} as Record, resimulating: null, onActionSelect: vi.fn(), onActionFavorite: vi.fn(), onActionReject: vi.fn(), onAssetClick: vi.fn(), onVlDoubleClick: vi.fn(), onCardEditMwChange: vi.fn(), onCardEditTapChange: vi.fn(), onResimulate: vi.fn(), onResimulateTap: vi.fn(), }; it('renders the action card with index and id in the header', () => { render(); expect(screen.getByTestId('action-card-act_1')).toBeInTheDocument(); expect(screen.getByText(/^#1/)).toBeInTheDocument(); }); it('hides the description at rest (progressive disclosure)', () => { render(); expect(screen.queryByText('Open line L1')).not.toBeInTheDocument(); }); it('reveals the description and disclosure region when viewing', () => { render(); expect(screen.getByText('Open line L1')).toBeInTheDocument(); expect(screen.getByTestId('action-card-act_1-disclosure')).toBeInTheDocument(); }); it('displays action index and id in header', () => { render(); expect(screen.getByText(/^#3/)).toBeInTheDocument(); }); // The severity badge is an icon-only pictogram; its wording lives in // the `title` (hover tooltip) / `aria-label`, not as visible text. it('shows "Solves overload" severity tooltip when max_rho is below monitoring factor', () => { render(); expect(screen.getByTitle('Solves overload')).toBeInTheDocument(); }); it('shows "Still overloaded" severity tooltip when max_rho exceeds monitoring factor', () => { const details = { ...baseDetails, max_rho: 0.98, is_rho_reduction: true }; render(); expect(screen.getByTitle('Still overloaded')).toBeInTheDocument(); }); it('shows "Solved — low margin" severity tooltip when max_rho is close to monitoring factor', () => { const details = { ...baseDetails, max_rho: 0.92 }; render(); expect(screen.getByTitle(/Solved.*low margin/)).toBeInTheDocument(); }); it('shows "divergent" severity tooltip for non-convergence actions', () => { const details = { ...baseDetails, non_convergence: 'AC did not converge' }; render(); expect(screen.getByTitle('divergent')).toBeInTheDocument(); expect(screen.getByText(/LoadFlow failure/)).toBeInTheDocument(); }); it('shows "islanded" severity tooltip for islanded actions', () => { const details = { ...baseDetails, is_islanded: true, disconnected_mw: 12.5 }; render(); expect(screen.getByTitle('islanded')).toBeInTheDocument(); expect(screen.getByText(/12\.5 MW disconnected/)).toBeInTheDocument(); }); it('marks the card with data-viewing="true" when isViewing is true', () => { // The viewing-state signal is now a higher-saturation left-edge // accent stripe + a `data-viewing` attribute on the card root, // not the old vertical "VIEWING" ribbon. Replacing the ribbon // was the largest scannability win in the progressive-disclosure // pass (docs/proposals/ui-design-critique.md, recommendation 2). render(); expect(screen.getByTestId('action-card-act_1')).toHaveAttribute('data-viewing', 'true'); expect(screen.queryByText('VIEWING')).not.toBeInTheDocument(); }); it('marks the card with data-viewing="false" when isViewing is false', () => { render(); expect(screen.getByTestId('action-card-act_1')).toHaveAttribute('data-viewing', 'false'); expect(screen.queryByText('VIEWING')).not.toBeInTheDocument(); }); it('does not render the disclosure region when isViewing is false', () => { render(); expect(screen.queryByTestId('action-card-act_1-disclosure')).not.toBeInTheDocument(); }); it('hosts the star/reject buttons in a hover-revealed rail (always present in DOM)', () => { // The rail's visibility is controlled by CSS (`.action-card-rail` // → opacity 0/1 driven by :hover and .is-viewing on the card). // The buttons remain in the DOM at all times so keyboard / // automation users can reach them. const { container } = render(); const rail = container.querySelector('.action-card-rail'); expect(rail).not.toBeNull(); expect(screen.getByTitle('Select this action')).toBeInTheDocument(); expect(screen.getByTitle('Reject this action')).toBeInTheDocument(); }); it('places severity icon before the title and star/reject rail in the header row', () => { // Structural spec: the header row contains (left to right) // the severity pictogram, then the title, then the hover- // revealed star/reject rail in the top-right corner. const { container } = render(); // The first child of the card is the header flex row const card = container.querySelector('[data-testid="action-card-act_1"]')!; const headerRow = card.children[0] as HTMLElement; // Left group: severity icon + title const leftGroup = headerRow.children[0] as HTMLElement; const severity = leftGroup.querySelector('[data-testid="action-card-act_1-severity"]'); const title = leftGroup.querySelector('h4'); expect(severity).not.toBeNull(); expect(title).not.toBeNull(); // Severity comes before title in DOM order const children = Array.from(leftGroup.children); expect(children.indexOf(severity!)).toBeLessThan(children.indexOf(title!)); // Right group: the rail sits as the second child of the header row const rail = headerRow.querySelector('.action-card-rail'); expect(rail).not.toBeNull(); expect(headerRow.children[1]).toBe(rail); }); it('calls onActionSelect when card is clicked', () => { const onActionSelect = vi.fn(); render(); fireEvent.click(screen.getByTestId('action-card-act_1')); expect(onActionSelect).toHaveBeenCalledWith('act_1'); }); it('shows star button when action is not selected', () => { render(); expect(screen.getByTitle('Select this action')).toBeInTheDocument(); }); it('hides star button when action is already selected', () => { render(); expect(screen.queryByTitle('Select this action')).not.toBeInTheDocument(); }); it('shows reject button when action is not rejected', () => { render(); expect(screen.getByTitle('Reject this action')).toBeInTheDocument(); }); it('hides reject button when action is already rejected', () => { render(); expect(screen.queryByTitle('Reject this action')).not.toBeInTheDocument(); }); it('calls onActionFavorite when star button is clicked', () => { const onActionFavorite = vi.fn(); render(); fireEvent.click(screen.getByTitle('Select this action')); expect(onActionFavorite).toHaveBeenCalledWith('act_1'); }); it('calls onActionReject when reject button is clicked', () => { const onActionReject = vi.fn(); render(); fireEvent.click(screen.getByTitle('Reject this action')); expect(onActionReject).toHaveBeenCalledWith('act_1'); }); it('displays max loading percentage and line name', () => { render(); expect(screen.getByText('Max loading:')).toBeInTheDocument(); expect(screen.getAllByText(/85\.0%/).length).toBeGreaterThan(0); expect(screen.getAllByTitle('Zoom to LINE_A').length).toBeGreaterThan(0); }); it('clicks on the max-loading line name zoom the new worst line, not the pre-action overload', () => { // Regression: the action re-distributes flows and the new worst // line (LINE_B) is NOT in linesOverloaded (which reflects the // pre-action N-1 overloads — only LINE_A here). Clicking // "LINE_B" in the "Max loading: X% on LINE_B" row must zoom on // LINE_B, not on any of the pre-action lines. const onAssetClick = vi.fn(); const details: ActionDetail = { ...baseDetails, rho_before: [1.05], rho_after: [0.72], // LINE_A is now below the limit max_rho: 0.967, // but LINE_B (newly overloaded) peaks at 96.7% max_rho_line: 'LINE_B', is_rho_reduction: true, }; render( ); // The displayed text must mention the new worst line expect(screen.getByText('96.7%')).toBeInTheDocument(); // Click specifically the "on LINE_B" button inside the Max // loading row — not the rho_after button (which would be on // LINE_A) and not any sticky-panel button (not in this test). const maxRhoButton = screen.getByTitle('Zoom to LINE_B'); fireEvent.click(maxRhoButton); expect(onAssetClick).toHaveBeenCalledTimes(1); expect(onAssetClick).toHaveBeenCalledWith('act_1', 'LINE_B', 'action'); }); it('renders the "Overload loading after" detail only inside the viewing disclosure', () => { // At rest the "Overload loading after" detail is hidden — the // per-card max ρ% summary above replaces it. Only the viewing // card expands the per-line breakdown. "Loading before" stays // in the sticky Overloads panel and never appears on the card. const { rerender } = render(); expect(screen.queryByText(/loading after/i)).not.toBeInTheDocument(); expect(screen.queryByText(/loading before/i)).not.toBeInTheDocument(); rerender(); expect(screen.getByText(/Overload loading after/i)).toBeInTheDocument(); expect(screen.queryByText(/loading before/i)).not.toBeInTheDocument(); }); it('renders the Source / provenance row BELOW the "Overload loading after" line in the unfolded card', () => { // Operator-requested layout: Source is a low-information // attribution row, so it sits at the very bottom of the // unfolded disclosure — below the operational fields // (description, per-line editors, "Overload loading after"). // Pre-change the row sat just under the description, which // pushed it above the more relevant operational data. const details: ActionDetail = { ...baseDetails, origin: 'user', }; render(); const disclosure = screen.getByTestId('action-card-act_1-disclosure'); const html = disclosure.innerHTML; const overloadIdx = html.indexOf('Overload loading after'); const sourceIdx = html.indexOf('Source:'); expect(overloadIdx).toBeGreaterThanOrEqual(0); expect(sourceIdx).toBeGreaterThanOrEqual(0); expect(sourceIdx).toBeGreaterThan(overloadIdx); }); it('renders load shedding details with MW input and re-simulate button when viewing', () => { const details: ActionDetail = { ...baseDetails, load_shedding_details: [ { load_name: 'LOAD_X', voltage_level_id: 'VL1', shedded_mw: 5.0 } ], }; render(); expect(screen.getByText(/Shedding on/)).toBeInTheDocument(); expect(screen.getByText('LOAD_X')).toBeInTheDocument(); expect(screen.getByTestId('edit-mw-act_1')).toBeInTheDocument(); expect(screen.getByTestId('resimulate-act_1')).toBeInTheDocument(); }); it('hides the load shedding editor at rest (progressive disclosure)', () => { const details: ActionDetail = { ...baseDetails, load_shedding_details: [ { load_name: 'LOAD_X', voltage_level_id: 'VL1', shedded_mw: 5.0 } ], }; render(); expect(screen.queryByText(/Shedding on/)).not.toBeInTheDocument(); expect(screen.queryByTestId('edit-mw-act_1')).not.toBeInTheDocument(); }); it('renders curtailment details with MW input and re-simulate button when viewing', () => { const details: ActionDetail = { ...baseDetails, curtailment_details: [ { gen_name: 'GEN_Y', voltage_level_id: 'VL2', curtailed_mw: 3.0 } ], }; render(); expect(screen.getByText(/Curtailment on/)).toBeInTheDocument(); expect(screen.getByText('GEN_Y')).toBeInTheDocument(); expect(screen.getByTestId('edit-mw-act_1')).toBeInTheDocument(); }); it('renders redispatch details with signed-delta MW input when viewing', () => { const details: ActionDetail = { ...baseDetails, redispatch_details: [ { gen_name: 'THERM_1', voltage_level_id: 'VL3', delta_mw: 10.0, target_mw: 40.0, direction: 'up' } ], }; render(); expect(screen.getByText(/Redispatch on/)).toBeInTheDocument(); expect(screen.getByText('THERM_1')).toBeInTheDocument(); expect(screen.getByTestId('edit-mw-act_1')).toBeInTheDocument(); expect(screen.getByTestId('resimulate-act_1')).toBeInTheDocument(); }); it('passes the signed delta (incl. negative) to onResimulate for redispatch', () => { const onResimulate = vi.fn(); const details: ActionDetail = { ...baseDetails, redispatch_details: [ { gen_name: 'THERM_1', voltage_level_id: 'VL3', delta_mw: -10.0, target_mw: 20.0, direction: 'down' } ], }; render(); fireEvent.click(screen.getByTestId('resimulate-act_1')); expect(onResimulate).toHaveBeenCalledWith('act_1', -10.0); }); it('renders a clickable VL chip for a redispatch action (zoom + SLD)', () => { const onVlDoubleClick = vi.fn(); const details: ActionDetail = { ...baseDetails, redispatch_details: [ { gen_name: 'THERM_1', voltage_level_id: 'VL_BIEST', delta_mw: 10.0, target_mw: 40.0, direction: 'up' } ], }; render(); const chip = screen.getByText('VL_BIEST'); expect(chip).toBeInTheDocument(); fireEvent.doubleClick(chip); expect(onVlDoubleClick).toHaveBeenCalledWith('act_1', 'VL_BIEST'); }); it('shows the max-raise headroom and clamps the delta to it on re-simulate', () => { const onResimulate = vi.fn(); const details: ActionDetail = { ...baseDetails, redispatch_details: [ { gen_name: 'THERM_1', voltage_level_id: 'VL3', delta_mw: 10.0, target_mw: 40.0, direction: 'up', current_mw: 30, max_raise_mw: 25, max_lower_mw: 30 } ], }; render(); expect(screen.getByText(/max raise: 25 MW/)).toBeInTheDocument(); fireEvent.click(screen.getByTestId('resimulate-act_1')); // 999 requested but clamped to the 25 MW raise headroom. expect(onResimulate).toHaveBeenCalledWith('act_1', 25); }); it('renders PST details with tap input and re-simulate button when viewing', () => { const details: ActionDetail = { ...baseDetails, pst_details: [ { pst_name: 'PST_Z', tap_position: 5, low_tap: -10, high_tap: 10 } ], }; render(); expect(screen.getByText('PST_Z')).toBeInTheDocument(); expect(screen.getByTestId('edit-tap-act_1')).toBeInTheDocument(); expect(screen.getByText('[-10..10]')).toBeInTheDocument(); }); it('calls onResimulate when load shedding re-simulate is clicked', () => { const onResimulate = vi.fn(); const details: ActionDetail = { ...baseDetails, load_shedding_details: [ { load_name: 'LOAD_X', voltage_level_id: 'VL1', shedded_mw: 5.0 } ], }; render(); fireEvent.click(screen.getByTestId('resimulate-act_1')); expect(onResimulate).toHaveBeenCalledWith('act_1', 5.0); }); it('calls onResimulateTap when PST re-simulate is clicked', () => { const onResimulateTap = vi.fn(); const details: ActionDetail = { ...baseDetails, pst_details: [ { pst_name: 'PST_Z', tap_position: 5, low_tap: -10, high_tap: 10 } ], }; render(); fireEvent.click(screen.getByTestId('resimulate-tap-act_1')); expect(onResimulateTap).toHaveBeenCalledWith('act_1', 5); }); it('shows "Simulating..." on re-simulate button when resimulating matches id', () => { const details: ActionDetail = { ...baseDetails, load_shedding_details: [ { load_name: 'LOAD_X', voltage_level_id: 'VL1', shedded_mw: 5.0 } ], }; render(); expect(screen.getByText('Simulating...')).toBeInTheDocument(); }); it('calls onCardEditMwChange when MW input value changes', () => { const onCardEditMwChange = vi.fn(); const details: ActionDetail = { ...baseDetails, load_shedding_details: [ { load_name: 'LOAD_X', voltage_level_id: 'VL1', shedded_mw: 5.0 } ], }; render(); fireEvent.change(screen.getByTestId('edit-mw-act_1'), { target: { value: '7.5' } }); expect(onCardEditMwChange).toHaveBeenCalledWith('act_1', '7.5'); }); it('shows "No reduction" severity tooltip when is_rho_reduction is false', () => { const details = { ...baseDetails, max_rho: 1.1, is_rho_reduction: false }; render(); expect(screen.getByTitle('No reduction')).toBeInTheDocument(); }); // Regression: for a combined action like // ``load_shedding_BEON3 TR311+reco_GEN.PY762``, the card used to // render ONLY the load-shedding voltage level as a clickable badge // and drop the reco's line because an ``if (isLoadShedding) { … // return } else { topology-based extraction }`` short-circuit // skipped the topology branch whenever load-shedding details were // present. Both sub-actions must now produce a badge. Same // expectation holds for ``curtailment + reco`` and for pairs where // one leg is a disco / coupling. it('renders both load-shedding VL and reco line for combined LS+reco action', () => { const details: ActionDetail = { ...baseDetails, load_shedding_details: [ { load_name: 'BEON3 TR311', voltage_level_id: 'BEON3', shedded_mw: 6.4 }, ], action_topology: { lines_ex_bus: { 'GEN.PY762': 1 }, lines_or_bus: {}, gens_bus: {}, loads_bus: {}, }, }; render( ); // Both sub-actions' impacted assets must be clickable. expect(screen.getByRole('button', { name: 'BEON3' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'GEN.PY762' })).toBeInTheDocument(); }); it('renders both curtailment VL and reco line for combined RC+reco action', () => { const details: ActionDetail = { ...baseDetails, curtailment_details: [ { gen_name: 'WIND_A', voltage_level_id: 'VL_WIND', curtailed_mw: 42.0 }, ], action_topology: { lines_ex_bus: {}, lines_or_bus: { 'LINE_R': 1 }, gens_bus: {}, loads_bus: {}, }, }; render( ); expect(screen.getByRole('button', { name: 'VL_WIND' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'LINE_R' })).toBeInTheDocument(); }); // The unfolded card shows where the action came from — "user" // (manual simulation) or the recommender model that produced it. describe('origin / "Source" row', () => { const models = [ { 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: [] }, ]; it('shows "Manual simulation (user)" for a user-originated action when viewing', () => { const details = { ...baseDetails, origin: 'user' }; render(); expect(screen.getByTestId('action-card-act_1-origin')) .toHaveTextContent('Source: Manual simulation (user)'); }); it('resolves a model id to its label for a recommender-originated action', () => { const details = { ...baseDetails, origin: 'random_overflow' }; render(); expect(screen.getByTestId('action-card-act_1-origin')) .toHaveTextContent('Source: Random (post overflow analysis)'); }); it('falls back to the raw origin id when no model matches', () => { const details = { ...baseDetails, origin: 'ml_policy_v9' }; render(); expect(screen.getByTestId('action-card-act_1-origin')) .toHaveTextContent('Source: ml_policy_v9'); }); it('does not render the Source row when origin is absent', () => { // baseDetails has no `origin` — legacy / un-tracked actions. render(); expect(screen.queryByTestId('action-card-act_1-origin')).not.toBeInTheDocument(); }); it('does not render the Source row on a folded (non-viewing) card', () => { const details = { ...baseDetails, origin: 'user' }; render(); expect(screen.queryByTestId('action-card-act_1-origin')).not.toBeInTheDocument(); }); }); describe('half-open overload annotation (charging current)', () => { it('annotates the loading with reactive power when a line is open one end', () => { const details: ActionDetail = { ...baseDetails, rho_after: [0.333], half_open_overloads: { LINE_A: 16.8 }, }; render( , ); // The 33.3 % is kept (it is physically real) AND explained. expect(screen.getByText(/33\.3%/)).toBeInTheDocument(); expect(screen.getByText(/open one end/i)).toBeInTheDocument(); expect(screen.getByText(/16\.8 MVAr/)).toBeInTheDocument(); }); it('does not annotate a normally-loaded line', () => { const details: ActionDetail = { ...baseDetails, rho_after: [0.85], // no half_open_overloads }; render( , ); expect(screen.queryByText(/open one end/i)).not.toBeInTheDocument(); expect(screen.queryByText(/MVAr/i)).not.toBeInTheDocument(); }); }); });