// 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();
        });
    });
});
