// 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 } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useSldOverlay } from './useSldOverlay';
vi.mock('../api', () => ({
api: {
getNSld: vi.fn(),
getN1Sld: vi.fn(),
getActionVariantSld: vi.fn(),
}
}));
vi.mock('../utils/interactionLogger', () => ({
interactionLogger: { record: vi.fn() },
}));
import { api } from '../api';
import { interactionLogger } from '../utils/interactionLogger';
describe('useSldOverlay', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('initializes with null vlOverlay', () => {
const { result } = renderHook(() => useSldOverlay('n'));
expect(result.current.vlOverlay).toBeNull();
});
it('initializes selectedContingencyForSld ref with an empty list', () => {
const { result } = renderHook(() => useSldOverlay('n'));
expect(result.current.selectedContingencyForSld.current).toEqual([]);
});
it('handleVlDoubleClick sets vlOverlay with loading state', () => {
vi.mocked(api.getNSld).mockImplementation(() => new Promise(() => { /* never resolves */ }));
const { result } = renderHook(() => useSldOverlay('n'));
act(() => {
result.current.handleVlDoubleClick('action_1', 'VL_400');
});
expect(result.current.vlOverlay).not.toBeNull();
expect(result.current.vlOverlay!.vlName).toBe('VL_400');
expect(result.current.vlOverlay!.actionId).toBe('action_1');
expect(result.current.vlOverlay!.loading).toBe(true);
expect(result.current.vlOverlay!.svg).toBeNull();
});
it('handleVlDoubleClick logs interaction', () => {
vi.mocked(api.getNSld).mockImplementation(() => new Promise(() => { /* never resolves */ }));
const { result } = renderHook(() => useSldOverlay('n'));
act(() => {
result.current.handleVlDoubleClick('action_1', 'VL_400');
});
expect(interactionLogger.record).toHaveBeenCalledWith('sld_overlay_opened', {
vl_name: 'VL_400',
action_id: 'action_1',
});
});
it('handleVlDoubleClick uses correct initial tab based on activeTab', () => {
vi.mocked(api.getN1Sld).mockImplementation(() => new Promise(() => { /* never resolves */ }));
const { result } = renderHook(() => useSldOverlay('contingency'));
act(() => {
result.current.handleVlDoubleClick('act1', 'VL_220');
});
expect(result.current.vlOverlay!.tab).toBe('contingency');
});
it('handleVlDoubleClick sets tab to action for action activeTab', () => {
vi.mocked(api.getActionVariantSld).mockImplementation(() => new Promise(() => { /* never resolves */ }));
const { result } = renderHook(() => useSldOverlay('action'));
act(() => {
result.current.handleVlDoubleClick('act1', 'VL_63');
});
expect(result.current.vlOverlay!.tab).toBe('action');
});
it('handleVlDoubleClick forceTab overrides the activeTab-derived initial tab', () => {
// The overflow-graph pin double-click drills straight into the
// post-action SLD regardless of which main tab is active.
vi.mocked(api.getActionVariantSld).mockImplementation(() => new Promise(() => { /* never resolves */ }));
const { result } = renderHook(() => useSldOverlay('n'));
act(() => {
result.current.handleVlDoubleClick('act1', 'VL_63', 'action');
});
expect(result.current.vlOverlay!.tab).toBe('action');
expect(api.getActionVariantSld).toHaveBeenCalled();
});
it('handleOverlayClose sets vlOverlay to null', () => {
vi.mocked(api.getNSld).mockImplementation(() => new Promise(() => { /* never resolves */ }));
const { result } = renderHook(() => useSldOverlay('n'));
act(() => {
result.current.handleVlDoubleClick('act1', 'VL_400');
});
expect(result.current.vlOverlay).not.toBeNull();
act(() => {
result.current.handleOverlayClose();
});
expect(result.current.vlOverlay).toBeNull();
});
it('handleOverlayClose logs interaction', () => {
const { result } = renderHook(() => useSldOverlay('n'));
act(() => {
result.current.handleOverlayClose();
});
expect(interactionLogger.record).toHaveBeenCalledWith('sld_overlay_closed');
});
it('handleOverlaySldTabChange does nothing when vlOverlay is null', () => {
const { result } = renderHook(() => useSldOverlay('n'));
act(() => {
result.current.handleOverlaySldTabChange('contingency');
});
// No error thrown, no overlay set
expect(result.current.vlOverlay).toBeNull();
});
it('handleOverlaySldTabChange logs interaction when vlOverlay exists', () => {
vi.mocked(api.getNSld).mockImplementation(() => new Promise(() => { /* never resolves */ }));
const { result } = renderHook(() => useSldOverlay('n'));
act(() => {
result.current.handleVlDoubleClick('act1', 'VL_400');
});
vi.mocked(api.getN1Sld).mockImplementation(() => new Promise(() => { /* never resolves */ }));
act(() => {
result.current.handleOverlaySldTabChange('contingency');
});
expect(interactionLogger.record).toHaveBeenCalledWith('sld_overlay_tab_changed', {
tab: 'contingency',
vl_name: 'VL_400',
});
});
it('setVlOverlay allows direct state manipulation', () => {
const { result } = renderHook(() => useSldOverlay('n'));
act(() => {
result.current.setVlOverlay({
vlName: 'VL_TEST',
actionId: null,
svg: '',
sldMetadata: null,
loading: false,
error: null,
tab: 'n',
});
});
expect(result.current.vlOverlay).not.toBeNull();
expect(result.current.vlOverlay!.vlName).toBe('VL_TEST');
});
it('fetchSldVariant resolves and populates vlOverlay with N SLD data', async () => {
vi.mocked(api.getNSld).mockResolvedValue({
svg: '',
sld_metadata: '{"nodes":[]}',
});
const { result } = renderHook(() => useSldOverlay('n'));
act(() => {
result.current.handleVlDoubleClick('act1', 'VL_400');
});
// Wait for the async fetch to complete
await vi.waitFor(() => {
expect(result.current.vlOverlay!.loading).toBe(false);
});
expect(result.current.vlOverlay!.svg).toBe('');
expect(result.current.vlOverlay!.sldMetadata).toBe('{"nodes":[]}');
});
it('fetchSldVariant handles error gracefully', async () => {
vi.mocked(api.getNSld).mockRejectedValue({
response: { data: { detail: 'Network error' } },
});
const { result } = renderHook(() => useSldOverlay('n'));
act(() => {
result.current.handleVlDoubleClick('act1', 'VL_400');
});
await vi.waitFor(() => {
expect(result.current.vlOverlay!.loading).toBe(false);
});
expect(result.current.vlOverlay!.error).toBe('Network error');
expect(result.current.vlOverlay!.svg).toBeNull();
});
// Regression for the "Action '' not found in last analysis
// result" bug: the operator opens the SLD overlay from the
// N tab (actionId = '') with a remedial action already
// selected, then clicks the ACTION sub-tab inside the overlay.
// Previously, handleOverlaySldTabChange forwarded the stored
// actionId ('') and the backend rejected the request. The
// hook now falls back to the `liveSelectedActionId` it was
// instantiated with so a real action id reaches the API.
describe('action sub-tab fallback (Action "" not found bug)', () => {
it('falls back to liveSelectedActionId when vlOverlay.actionId is empty', async () => {
vi.mocked(api.getNSld).mockResolvedValue({
svg: '',
sld_metadata: null,
});
vi.mocked(api.getActionVariantSld).mockResolvedValue({
svg: '',
sld_metadata: null,
flow_deltas: {},
reactive_flow_deltas: {},
asset_deltas: {},
changed_switches: {},
});
// The hook is instantiated from the main (N) tab with
// an already-selected action 'ACT_42'.
const { result } = renderHook(() => useSldOverlay('n', 'ACT_42'));
// Open the SLD with an empty actionId — this is what
// App.tsx USED to do when the activeTab was 'n'.
act(() => {
result.current.handleVlDoubleClick('', 'VL_400');
});
await vi.waitFor(() => {
expect(result.current.vlOverlay!.loading).toBe(false);
});
// Clear mocks so we only see the upcoming call.
vi.mocked(api.getActionVariantSld).mockClear();
// Now the user flips the overlay sub-tab to 'action'.
act(() => {
result.current.handleOverlaySldTabChange('action');
});
await vi.waitFor(() => {
expect(result.current.vlOverlay!.loading).toBe(false);
});
// The API must have been called with the LIVE
// selectedActionId ('ACT_42'), NOT with the empty
// actionId stored on vlOverlay. This proves the
// fallback kicked in.
expect(api.getActionVariantSld).toHaveBeenCalledWith('ACT_42', 'VL_400');
// And the overlay now holds the resolved action id so
// subsequent highlight passes can find it.
expect(result.current.vlOverlay!.actionId).toBe('ACT_42');
// No error should have been set.
expect(result.current.vlOverlay!.error).toBeNull();
});
it('shows a friendly error when no action is available at all', async () => {
vi.mocked(api.getNSld).mockResolvedValue({
svg: '',
sld_metadata: null,
});
// No liveSelectedActionId passed → the fallback has
// nothing to fall back to.
const { result } = renderHook(() => useSldOverlay('n'));
act(() => {
result.current.handleVlDoubleClick('', 'VL_400');
});
await vi.waitFor(() => {
expect(result.current.vlOverlay!.loading).toBe(false);
});
act(() => {
result.current.handleOverlaySldTabChange('action');
});
await vi.waitFor(() => {
expect(result.current.vlOverlay!.loading).toBe(false);
});
// No API call should have been issued (we don't want
// the backend to see the empty-string id and throw).
expect(api.getActionVariantSld).not.toHaveBeenCalled();
// Instead, a friendly inline error is shown.
expect(result.current.vlOverlay!.error).toMatch(/no action selected/i);
});
it('prefers the explicit actionId when one is stored on vlOverlay', async () => {
vi.mocked(api.getActionVariantSld).mockResolvedValue({
svg: '',
sld_metadata: null,
flow_deltas: {},
reactive_flow_deltas: {},
asset_deltas: {},
changed_switches: {},
});
// Hook instantiated with a "live" selection of ACT_B,
// but the overlay was opened with the explicit
// argument ACT_A — the explicit id must win.
const { result } = renderHook(() => useSldOverlay('action', 'ACT_B'));
act(() => {
result.current.handleVlDoubleClick('ACT_A', 'VL_400');
});
await vi.waitFor(() => {
expect(result.current.vlOverlay!.loading).toBe(false);
});
// First call uses the explicit ACT_A.
expect(api.getActionVariantSld).toHaveBeenCalledWith('ACT_A', 'VL_400');
});
});
describe('refreshCurrentIfAction', () => {
// Regression: before this wiring a manual re-simulation (MW edit
// on load-shedding / curtailment, tap edit on PST) updated
// `result.actions[actionId]` but the SLD overlay kept showing
// the pre-resimulation highlights and flow deltas because
// `vlOverlay.svg` was stale.
it('re-fetches the SLD when the overlay matches the given action', async () => {
vi.mocked(api.getActionVariantSld).mockResolvedValue({
svg: '',
sld_metadata: null,
flow_deltas: {},
reactive_flow_deltas: {},
asset_deltas: {},
changed_switches: {},
} as unknown as Awaited>);
const { result } = renderHook(() => useSldOverlay('action'));
act(() => {
result.current.handleVlDoubleClick('ACT_LS', 'VL_400');
});
await vi.waitFor(() => {
expect(result.current.vlOverlay!.loading).toBe(false);
});
expect(api.getActionVariantSld).toHaveBeenCalledTimes(1);
act(() => {
result.current.refreshCurrentIfAction('ACT_LS');
});
await vi.waitFor(() => {
expect(api.getActionVariantSld).toHaveBeenCalledTimes(2);
});
expect(api.getActionVariantSld).toHaveBeenLastCalledWith('ACT_LS', 'VL_400');
});
it('does nothing when the overlay is closed', () => {
const { result } = renderHook(() => useSldOverlay('n'));
expect(result.current.vlOverlay).toBeNull();
act(() => {
result.current.refreshCurrentIfAction('ACT_LS');
});
expect(api.getActionVariantSld).not.toHaveBeenCalled();
});
it('does nothing when the overlay is open on a DIFFERENT action', async () => {
vi.mocked(api.getActionVariantSld).mockResolvedValue({
svg: '',
sld_metadata: null,
flow_deltas: {},
reactive_flow_deltas: {},
asset_deltas: {},
changed_switches: {},
} as unknown as Awaited>);
const { result } = renderHook(() => useSldOverlay('action'));
act(() => {
result.current.handleVlDoubleClick('ACT_A', 'VL_400');
});
await vi.waitFor(() => {
expect(result.current.vlOverlay!.loading).toBe(false);
});
vi.mocked(api.getActionVariantSld).mockClear();
act(() => {
result.current.refreshCurrentIfAction('ACT_OTHER');
});
expect(api.getActionVariantSld).not.toHaveBeenCalled();
});
});
});