Co-Study4Grid / frontend /src /hooks /useDiagramHighlights.test.ts
github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
8.49 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
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useDiagramHighlights } from './useDiagramHighlights';
import type { AnalysisResult, DiagramData, MetadataIndex } from '../types';
import type { DiagramsState } from './useDiagrams';
vi.mock('../utils/svgUtils', () => ({
applyContingencyHighlight: vi.fn(),
applyDeltaVisuals: vi.fn(),
applyOverloadedHighlights: vi.fn(),
applyActionTargetHighlights: vi.fn(),
}));
vi.mock('../utils/overloadHighlights', () => ({
computeN1OverloadHighlights: vi.fn((analysisOverloads: string[], n1Overloads: string[] | undefined) => {
if (analysisOverloads && analysisOverloads.length > 0) return analysisOverloads;
return n1Overloads ?? [];
}),
}));
import {
applyContingencyHighlight,
applyDeltaVisuals,
applyOverloadedHighlights,
applyActionTargetHighlights,
} from '../utils/svgUtils';
const makeContainer = (): { current: HTMLElement } => ({
current: document.createElement('div'),
});
const makeMetaIndex = (): MetadataIndex => ({
nodesByEquipmentId: new Map(),
nodesBySvgId: new Map(),
edgesByEquipmentId: new Map(),
edgesByNode: new Map(),
} as MetadataIndex);
const makeDiagramsState = (overrides: Partial<DiagramsState> = {}): DiagramsState => {
const n1Diagram = { svg: '<svg></svg>', lines_overloaded: ['OVL_LINE'] } as unknown as DiagramData;
const actionDiagram = { svg: '<svg></svg>' } as unknown as DiagramData;
const base = {
activeTab: 'contingency',
nDiagram: { svg: '<svg></svg>' } as unknown as DiagramData,
n1Diagram,
actionDiagram,
actionDiagramLoading: false,
selectedActionId: null,
actionViewMode: 'network',
nMetaIndex: makeMetaIndex(),
n1MetaIndex: makeMetaIndex(),
actionMetaIndex: makeMetaIndex(),
nSvgContainerRef: makeContainer(),
n1SvgContainerRef: makeContainer(),
actionSvgContainerRef: makeContainer(),
setActionViewMode: vi.fn(),
} as unknown as DiagramsState;
return { ...base, ...overrides } as DiagramsState;
};
// Regression: the call order drives the DOM append order in
// `#nad-background-layer`, which in turn drives visual stacking —
// later-appended = drawn on top. The product spec is
// "action halo > overload halo > contingency halo" (bottom to top).
// Before the fix, the hook invoked overload → contingency on N-1 and
// overload → action → contingency on the Remedial Action tab, which
// placed the yellow contingency halo on top of the pink action halo.
describe('useDiagramHighlights — halo call order matches product spec', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('N-1 tab invokes contingency BEFORE overload (so overload draws on top)', () => {
const result = {
lines_overloaded: ['OVL_LINE'],
actions: {},
action_scores: {},
} as unknown as AnalysisResult;
const diagrams = makeDiagramsState({ activeTab: 'contingency' });
renderHook(() => useDiagramHighlights({
diagrams,
result,
selectedBranch: 'CONT_LINE',
selectedOverloads: new Set<string>(),
monitoringFactor: 0.95,
detachedTabs: {},
}));
const contingencyOrder = (applyContingencyHighlight as unknown as { mock: { invocationCallOrder: number[] } })
.mock.invocationCallOrder[0];
const overloadOrder = (applyOverloadedHighlights as unknown as { mock: { invocationCallOrder: number[] } })
.mock.invocationCallOrder[0];
const deltaOrder = (applyDeltaVisuals as unknown as { mock: { invocationCallOrder: number[] } })
.mock.invocationCallOrder[0];
expect(contingencyOrder).toBeDefined();
expect(overloadOrder).toBeDefined();
expect(deltaOrder).toBeDefined();
// Contingency first (bottom), overload next (top), deltas last
// (decorate originals, don't affect the clone cascade).
expect(contingencyOrder).toBeLessThan(overloadOrder);
expect(overloadOrder).toBeLessThan(deltaOrder);
});
it('Remedial Action tab invokes contingency → overload → action (bottom → top)', () => {
const actionDetail = {
description_unitaire: "Ouvrir 'ACT_LINE'",
action_topology: { lines_ex_bus: { ACT_LINE: -1 } },
max_rho: 1.2,
lines_overloaded_after: ['OVL_LINE'],
};
const result = {
lines_overloaded: ['OVL_LINE'],
actions: { 'act-1': actionDetail },
action_scores: {},
} as unknown as AnalysisResult;
const diagrams = makeDiagramsState({
activeTab: 'action',
selectedActionId: 'act-1',
});
renderHook(() => useDiagramHighlights({
diagrams,
result,
selectedBranch: 'CONT_LINE',
selectedOverloads: new Set<string>(),
monitoringFactor: 0.95,
detachedTabs: {},
}));
const contingencyOrder = (applyContingencyHighlight as unknown as { mock: { invocationCallOrder: number[] } })
.mock.invocationCallOrder[0];
const overloadOrder = (applyOverloadedHighlights as unknown as { mock: { invocationCallOrder: number[] } })
.mock.invocationCallOrder[0];
const actionOrder = (applyActionTargetHighlights as unknown as { mock: { invocationCallOrder: number[] } })
.mock.invocationCallOrder[0];
expect(contingencyOrder).toBeDefined();
expect(overloadOrder).toBeDefined();
expect(actionOrder).toBeDefined();
// Bottom → top.
expect(contingencyOrder).toBeLessThan(overloadOrder);
expect(overloadOrder).toBeLessThan(actionOrder);
});
});
// QW14: the highlight pipeline used to re-run on every pan/zoom settle because
// applyHighlightsForTab depended on the whole `diagrams` object, whose identity
// churns when the per-tab pan/zoom (nPZ/n1PZ/actionPZ) changes. Narrowing that
// dependency to the specific members the callback reads (the two container refs
// + two metaIndexes) makes a settle a no-op for highlighting.
describe('useDiagramHighlights — no re-run on pan/zoom settle (QW14)', () => {
const callCount = (fn: unknown) =>
(fn as unknown as { mock: { calls: unknown[] } }).mock.calls.length;
beforeEach(() => vi.clearAllMocks());
it('does not re-invoke the highlight helpers when only the diagrams object identity changes', () => {
const diagrams = makeDiagramsState({ activeTab: 'contingency' });
const result = {
lines_overloaded: ['OVL_LINE'], actions: {}, action_scores: {},
} as unknown as AnalysisResult;
// Hoisted so ONLY the `diagrams` object identity changes on rerender —
// otherwise a fresh Set / {} each render would trip other effect deps.
const selectedOverloads = new Set<string>();
const detachedTabs = {};
const props = (d: DiagramsState) => ({
diagrams: d,
result,
selectedBranch: 'CONT_LINE',
selectedOverloads,
monitoringFactor: 0.95,
detachedTabs,
});
const { rerender } = renderHook(
(p: ReturnType<typeof props>) => useDiagramHighlights(p),
{ initialProps: props(diagrams) },
);
// Initial pass ran the pipeline.
expect(callCount(applyContingencyHighlight)).toBeGreaterThan(0);
vi.clearAllMocks();
// Simulate a pan/zoom settle: a NEW `diagrams` object identity, but every
// member (container refs, metaIndexes, the three diagrams) is the SAME
// reference. With the narrowed deps this must not re-run the pipeline.
rerender(props({ ...diagrams }));
expect(callCount(applyContingencyHighlight)).toBe(0);
expect(callCount(applyOverloadedHighlights)).toBe(0);
expect(callCount(applyDeltaVisuals)).toBe(0);
});
});