Co-Study4Grid / frontend /src /components /SidebarSummary.test.tsx
github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
15.1 kB
// Copyright (c) 2025-2026, RTE (https://www.rte-france.com)
// This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0.
// If a copy of the Mozilla Public License, version 2.0 was not distributed with this file,
// you can obtain one at http://mozilla.org/MPL/2.0/.
// SPDX-License-Identifier: MPL-2.0
// This file is part of Co-Study4Grid a Power Grid Study tool Assistant Interface to help solve contigencies for a grid state under study.
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent, within } from '@testing-library/react';
import '@testing-library/jest-dom/vitest';
import SidebarSummary from './SidebarSummary';
import { DEFAULT_ACTION_OVERVIEW_FILTERS } from '../utils/actionTypes';
import type { ActionOverviewFilters } from '../types';
describe('SidebarSummary', () => {
const baseProps = {
selectedContingency: [] as string[],
n1LinesOverloaded: undefined as string[] | undefined,
n1LinesOverloadedRho: undefined as number[] | undefined,
selectedOverloads: undefined as Set<string> | null | undefined,
displayName: (id: string) => id,
onContingencyZoom: vi.fn(),
onOverloadClick: vi.fn(),
};
it('renders nothing when there is no contingency, no overloads and no filters', () => {
const { container } = render(<SidebarSummary {...baseProps} />);
expect(container.firstChild).toBeNull();
});
it('renders the contingency row with a zoom-to button', () => {
const onContingencyZoom = vi.fn();
render(
<SidebarSummary
{...baseProps}
selectedContingency={['LINE_A']}
onContingencyZoom={onContingencyZoom}
/>,
);
const strip = screen.getByTestId('sticky-feed-summary');
expect(strip).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'LINE_A' }));
expect(onContingencyZoom).toHaveBeenCalledWith('LINE_A');
});
it('uses the ⚡ lightning pictogram for the Contingency label (replaces the old 🎯)', () => {
// Visual contract: the Contingency-as-fault metaphor is
// pinned on the ⚡ glyph across the sidebar (status line +
// picker card title). The legacy 🎯 must not regress here.
render(
<SidebarSummary
{...baseProps}
selectedContingency={['LINE_A']}
/>,
);
const strip = screen.getByTestId('sticky-feed-summary');
expect(strip.textContent).toContain('⚡ Contingency');
expect(strip.textContent).not.toContain('🎯');
});
it('renders the overloads row with per-line jump buttons', () => {
const onOverloadClick = vi.fn();
render(
<SidebarSummary
{...baseProps}
n1LinesOverloaded={['LINE_X']}
n1LinesOverloadedRho={[1.05]}
onOverloadClick={onOverloadClick}
/>,
);
expect(screen.getByText(/Overloads:/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'LINE_X' }));
expect(onOverloadClick).toHaveBeenCalledWith('', 'LINE_X', 'contingency');
});
it('renders the ActionFilterRings when hasActions + filters + onChange are all wired', () => {
render(
<SidebarSummary
{...baseProps}
selectedContingency={['LINE_A']}
overviewFilters={DEFAULT_ACTION_OVERVIEW_FILTERS as ActionOverviewFilters}
onOverviewFiltersChange={vi.fn()}
hasActions
/>,
);
expect(screen.getByTestId('sidebar-action-filters')).toBeInTheDocument();
});
it('hides the filter rings while the feed has no action to filter (hasActions=false)', () => {
render(
<SidebarSummary
{...baseProps}
selectedContingency={['LINE_A']}
overviewFilters={DEFAULT_ACTION_OVERVIEW_FILTERS as ActionOverviewFilters}
onOverviewFiltersChange={vi.fn()}
hasActions={false}
/>,
);
expect(screen.queryByTestId('sidebar-action-filters')).not.toBeInTheDocument();
});
it('hides the filter rings when no overviewFilters object is supplied', () => {
render(
<SidebarSummary
{...baseProps}
selectedContingency={['LINE_A']}
hasActions
/>,
);
expect(screen.queryByTestId('sidebar-action-filters')).not.toBeInTheDocument();
});
it('renders the strip for the filter rings alone, below the overloads row', () => {
render(
<SidebarSummary
{...baseProps}
n1LinesOverloaded={['LINE_X']}
n1LinesOverloadedRho={[1.05]}
overviewFilters={DEFAULT_ACTION_OVERVIEW_FILTERS as ActionOverviewFilters}
onOverviewFiltersChange={vi.fn()}
hasActions
/>,
);
const overloads = screen.getByText(/Overloads:/i);
const filters = screen.getByTestId('sidebar-action-filters');
// Filter rings come AFTER the overloads row in document order.
expect(overloads.compareDocumentPosition(filters) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it('renders the strip even when ONLY the filter rings are present', () => {
render(
<SidebarSummary
{...baseProps}
overviewFilters={DEFAULT_ACTION_OVERVIEW_FILTERS as ActionOverviewFilters}
onOverviewFiltersChange={vi.fn()}
hasActions
/>,
);
expect(screen.getByTestId('sticky-feed-summary')).toBeInTheDocument();
expect(screen.getByTestId('sidebar-action-filters')).toBeInTheDocument();
});
// ===== readability-feed PR: Clear button =====
describe('Clear contingency shortcut', () => {
it('renders the Clear button next to the contingency label when a handler is wired', () => {
render(
<SidebarSummary
{...baseProps}
selectedContingency={['LINE_A']}
onClearContingency={vi.fn()}
/>,
);
const clearBtn = screen.getByTestId('sidebar-summary-clear');
expect(clearBtn).toBeInTheDocument();
expect(clearBtn.textContent).toMatch(/Clear/);
});
it('fires onClearContingency when the Clear button is clicked', () => {
const onClearContingency = vi.fn();
render(
<SidebarSummary
{...baseProps}
selectedContingency={['LINE_A']}
onClearContingency={onClearContingency}
/>,
);
fireEvent.click(screen.getByTestId('sidebar-summary-clear'));
expect(onClearContingency).toHaveBeenCalledTimes(1);
});
it('omits the Clear button when no handler is provided', () => {
render(
<SidebarSummary
{...baseProps}
selectedContingency={['LINE_A']}
/>,
);
expect(screen.queryByTestId('sidebar-summary-clear')).not.toBeInTheDocument();
});
it('omits the Clear button when there is no contingency to clear', () => {
render(
<SidebarSummary
{...baseProps}
n1LinesOverloaded={['LINE_X']}
n1LinesOverloadedRho={[1.05]}
onClearContingency={vi.fn()}
/>,
);
expect(screen.queryByTestId('sidebar-summary-clear')).not.toBeInTheDocument();
});
});
// ===== readability-feed PR: double-click overload toggle =====
describe('Overload double-click toggle', () => {
it('toggles the overload via onToggleOverload on double-click', () => {
const onToggleOverload = vi.fn();
render(
<SidebarSummary
{...baseProps}
selectedContingency={['LINE_A']}
n1LinesOverloaded={['LINE_X', 'LINE_Y']}
n1LinesOverloadedRho={[1.05, 1.02]}
onToggleOverload={onToggleOverload}
/>,
);
fireEvent.doubleClick(screen.getByRole('button', { name: 'LINE_X' }));
expect(onToggleOverload).toHaveBeenCalledWith('LINE_X');
});
it('single-click still routes to onOverloadClick (zoom shortcut) regardless of toggle wiring', () => {
const onOverloadClick = vi.fn();
render(
<SidebarSummary
{...baseProps}
selectedContingency={['LINE_A']}
n1LinesOverloaded={['LINE_X']}
n1LinesOverloadedRho={[1.05]}
onOverloadClick={onOverloadClick}
onToggleOverload={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'LINE_X' }));
expect(onOverloadClick).toHaveBeenCalledWith('', 'LINE_X', 'contingency');
});
it('does nothing on double-click when no toggle handler is wired', () => {
// The double-click branch is gated on `onToggleOverload` —
// without it the dbl-click is a no-op (the existing single-
// click zoom semantics are preserved by the click handler).
const onToggleOverload = vi.fn();
render(
<SidebarSummary
{...baseProps}
selectedContingency={['LINE_A']}
n1LinesOverloaded={['LINE_X']}
n1LinesOverloadedRho={[1.05]}
// onToggleOverload intentionally omitted
/>,
);
fireEvent.doubleClick(screen.getByRole('button', { name: 'LINE_X' }));
expect(onToggleOverload).not.toHaveBeenCalled();
});
it('shows the deselected style when an overload is not in selectedOverloads', () => {
const selectedOverloads = new Set<string>();
const { container } = render(
<SidebarSummary
{...baseProps}
selectedContingency={['LINE_A']}
n1LinesOverloaded={['LINE_X']}
n1LinesOverloadedRho={[1.05]}
selectedOverloads={selectedOverloads}
/>,
);
const link = screen.getByRole('button', { name: 'LINE_X' });
// Deselected links carry weight 400 and no underline (vs 600
// + underline-dotted for selected) — matches the OverloadPanel
// contract preserved by the readability-feed PR.
const style = link.getAttribute('style') || '';
expect(style).toMatch(/font-weight:\s*400/);
expect(style).not.toMatch(/underline/);
expect(container).toBeInTheDocument();
});
});
// ===== readability-feed PR: overload info bubble =====
describe('Overload info bubble', () => {
const overloadProps = {
...baseProps,
selectedContingency: ['LINE_A'],
n1LinesOverloaded: ['LINE_X', 'LINE_Y'],
n1LinesOverloadedRho: [1.05, 1.02],
};
it('renders the bubble icon when overloads are present', () => {
render(<SidebarSummary {...overloadProps} />);
expect(screen.getByTestId('sidebar-summary-overloads-bubble')).toBeInTheDocument();
});
it('opens the popover on click and shows N-overloads + N-1 toggles + monitor-deselected', () => {
const onToggleOverload = vi.fn();
const onToggleMonitorDeselected = vi.fn();
render(
<SidebarSummary
{...overloadProps}
nLinesOverloaded={['LINE_N_PRE']}
nLinesOverloadedRho={[1.10]}
selectedOverloads={new Set(['LINE_X'])} // LINE_Y deselectedmonitor-deselected toggle shows
onToggleOverload={onToggleOverload}
onToggleMonitorDeselected={onToggleMonitorDeselected}
monitorDeselected={false}
monitoringHint="100/200 lines monitored — see Notices for details."
/>,
);
fireEvent.click(screen.getByTestId('sidebar-summary-overloads-bubble'));
const popover = screen.getByTestId('overload-info-popover');
expect(popover).toBeInTheDocument();
expect(popover.textContent).toMatch(/100\/200 lines monitored/);
expect(within(popover).getByText('LINE_N_PRE')).toBeInTheDocument();
expect(within(popover).getByTestId('overload-info-toggle-LINE_X')).toBeInTheDocument();
expect(within(popover).getByTestId('overload-info-toggle-LINE_Y')).toBeInTheDocument();
expect(within(popover).getByTestId('overload-info-monitor-deselected')).toBeInTheDocument();
});
it('toggles the overload from the popover checkbox', () => {
const onToggleOverload = vi.fn();
render(
<SidebarSummary
{...overloadProps}
selectedOverloads={new Set(['LINE_X', 'LINE_Y'])}
onToggleOverload={onToggleOverload}
/>,
);
fireEvent.click(screen.getByTestId('sidebar-summary-overloads-bubble'));
const popover = screen.getByTestId('overload-info-popover');
const checkbox = within(popover).getByTestId('overload-info-toggle-LINE_X')
.querySelector('input[type="checkbox"]') as HTMLInputElement;
fireEvent.click(checkbox);
expect(onToggleOverload).toHaveBeenCalledWith('LINE_X');
});
it('hides the monitor-deselected toggle when every overload is selected', () => {
render(
<SidebarSummary
{...overloadProps}
selectedOverloads={new Set(['LINE_X', 'LINE_Y'])}
onToggleMonitorDeselected={vi.fn()}
/>,
);
fireEvent.click(screen.getByTestId('sidebar-summary-overloads-bubble'));
expect(screen.queryByTestId('overload-info-monitor-deselected')).not.toBeInTheDocument();
});
it('falls back to "None" for N-overloads when the list is empty', () => {
render(<SidebarSummary {...overloadProps} />);
fireEvent.click(screen.getByTestId('sidebar-summary-overloads-bubble'));
const popover = screen.getByTestId('overload-info-popover');
expect(popover.textContent).toMatch(/N Overloads:\s*None/);
});
});
});