Co-Study4Grid / frontend /src /components /MemoizedSvgContainer.test.tsx
github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
12.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 } from '@testing-library/react';
import '@testing-library/jest-dom/vitest';
import { createRef } from 'react';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import MemoizedSvgContainer from './MemoizedSvgContainer';
import { processSvg } from '../utils/svgUtils';
describe('MemoizedSvgContainer', () => {
it('renders a container div with correct id and class', () => {
const containerRef = createRef<HTMLDivElement>();
const { container } = render(
<MemoizedSvgContainer svg="" containerRef={containerRef} display="block" tabId="n" />
);
const el = container.querySelector('#n-svg-container');
expect(el).toBeInTheDocument();
expect(el).toHaveClass('svg-container');
});
it('sets display style based on prop', () => {
const containerRef = createRef<HTMLDivElement>();
const { container } = render(
<MemoizedSvgContainer svg="" containerRef={containerRef} display="none" tabId="contingency" />
);
const el = container.querySelector('#contingency-svg-container') as HTMLElement;
expect(el.style.display).toBe('none');
});
it('injects string SVG into container via innerHTML', () => {
const containerRef = createRef<HTMLDivElement>();
const svgString = '<svg><circle r="10"/></svg>';
render(
<MemoizedSvgContainer svg={svgString} containerRef={containerRef} display="block" tabId="n" />
);
expect(containerRef.current?.innerHTML).toContain('<circle');
});
it('injects SVGSVGElement into container via replaceChildren', () => {
const containerRef = createRef<HTMLDivElement>();
const svgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
circle.setAttribute('r', '20');
svgEl.appendChild(circle);
render(
<MemoizedSvgContainer svg={svgEl} containerRef={containerRef} display="block" tabId="action" />
);
expect(containerRef.current?.querySelector('circle')).toBeTruthy();
});
// ===== D6: the SVG element-adoption pipeline =====
// processSvg hands us an already-parsed SVGSVGElement; MemoizedSvgContainer
// must adopt that exact element via replaceChildren (no serialize +
// innerHTML re-parse). Proven by identity: the mounted <svg> is the SAME
// instance processSvg returned, which innerHTML could never produce.
it('adopts the exact element processSvg returned (no re-parse round-trip)', () => {
const containerRef = createRef<HTMLDivElement>();
const raw = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><g id="marker"/></svg>';
const { svg } = processSvg(raw, 100);
expect(typeof svg).not.toBe('string'); // element rail engaged
render(
<MemoizedSvgContainer svg={svg} containerRef={containerRef} display="block" tabId="n" />
);
// Identity check: same element instance moved into the container.
expect(containerRef.current?.firstElementChild).toBe(svg);
expect(containerRef.current?.querySelector('#marker')).toBeTruthy();
});
it('uses correct id for different tabId values', () => {
const containerRef = createRef<HTMLDivElement>();
const { container } = render(
<MemoizedSvgContainer svg="" containerRef={containerRef} display="block" tabId="action" />
);
expect(container.querySelector('#action-svg-container')).toBeInTheDocument();
});
it('logs performance timing to console', () => {
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { /* noop */ });
const containerRef = createRef<HTMLDivElement>();
render(
<MemoizedSvgContainer svg="<svg></svg>" containerRef={containerRef} display="block" tabId="n" />
);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('[SVG] DOM injection for n'));
consoleSpy.mockRestore();
});
// ===== Regression tests for the auto-zoom double-injection fix =====
// See VisualizationPanel.tsx — MemoizedSvgContainer is kept always mounted
// with an empty svg string as placeholder, so that when the real SVG arrives
// the component updates (not remounts) and StrictMode does NOT double-invoke
// the layout effect (which would overwrite a freshly-applied auto-zoom).
it('does NOT inject content when svg is empty string', () => {
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { /* noop */ });
const containerRef = createRef<HTMLDivElement>();
render(
<MemoizedSvgContainer svg="" containerRef={containerRef} display="block" tabId="contingency" />
);
// No DOM injection log should have been emitted
expect(consoleSpy).not.toHaveBeenCalledWith(expect.stringContaining('[SVG] DOM injection'));
// Container should be empty
expect(containerRef.current?.innerHTML).toBe('');
consoleSpy.mockRestore();
});
it('injects once when svg prop transitions from empty to non-empty (update, not remount)', () => {
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { /* noop */ });
const containerRef = createRef<HTMLDivElement>();
const { rerender } = render(
<MemoizedSvgContainer svg="" containerRef={containerRef} display="block" tabId="contingency" />
);
// Initial render with empty svg: no injection
expect(consoleSpy).not.toHaveBeenCalled();
// Transition to real SVG (simulates fetchN1 completing)
rerender(
<MemoizedSvgContainer svg="<svg><rect/></svg>" containerRef={containerRef} display="block" tabId="contingency" />
);
// Exactly one injection should have fired (the update), not two
const injectionCalls = consoleSpy.mock.calls.filter(
call => typeof call[0] === 'string' && call[0].includes('[SVG] DOM injection for contingency')
);
expect(injectionCalls.length).toBe(1);
expect(containerRef.current?.innerHTML).toContain('<rect');
consoleSpy.mockRestore();
});
it('preserves manually-set viewBox attribute on subsequent re-renders with same svg', () => {
const containerRef = createRef<HTMLDivElement>();
const svgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svgEl.setAttribute('viewBox', '0 0 100 100');
const { rerender } = render(
<MemoizedSvgContainer svg={svgEl} containerRef={containerRef} display="block" tabId="contingency" />
);
// Simulate auto-zoom setting a new viewBox on the live DOM
const liveSvg = containerRef.current?.querySelector('svg');
expect(liveSvg).toBeTruthy();
liveSvg!.setAttribute('viewBox', '25 25 10 10');
// Re-render with the SAME svg prop (React.memo + stable ref) — this
// should not re-run the layout effect and must preserve our viewBox.
rerender(
<MemoizedSvgContainer svg={svgEl} containerRef={containerRef} display="block" tabId="contingency" />
);
expect(containerRef.current?.querySelector('svg')?.getAttribute('viewBox')).toBe('25 25 10 10');
});
// ===== VL-names toggle: hide-rule contract =====
// Regression for the bug where toggling VL names off hid the label
// boxes but left dangling connector ticks (`.nad-text-edges`) from
// each voltage-level node to its now-invisible label. The CSS rule
// in App.css must list every shape pypowsybl emits for VL labels
// AND the connector-edge group, otherwise the toggle is incomplete.
it('applies nad-hide-vl-labels class on the container when hideVlLabels is true', () => {
const containerRef = createRef<HTMLDivElement>();
const { container } = render(
<MemoizedSvgContainer svg="" containerRef={containerRef} display="block" tabId="n" hideVlLabels={true} />
);
const el = container.querySelector('#n-svg-container');
expect(el).toHaveClass('nad-hide-vl-labels');
});
it('omits nad-hide-vl-labels class when hideVlLabels is false (default)', () => {
const containerRef = createRef<HTMLDivElement>();
const { container } = render(
<MemoizedSvgContainer svg="" containerRef={containerRef} display="block" tabId="n" />
);
const el = container.querySelector('#n-svg-container');
expect(el).not.toHaveClass('nad-hide-vl-labels');
});
it('App.css hides .nad-text-edges (the VL→label connector tick) when nad-hide-vl-labels is set', () => {
const css = readFileSync(resolve(__dirname, '..', 'App.css'), 'utf-8');
// Locate the hide-vl-labels rule block (selectors split across
// lines, ending with `display: none !important;`).
const ruleMatch = css.match(
/((?:\.svg-container\.nad-hide-vl-labels[^,{]*,\s*)+\.svg-container\.nad-hide-vl-labels[^,{]*)\s*\{[^}]*display:\s*none\s*!important[^}]*\}/s,
);
expect(ruleMatch).not.toBeNull();
const selectorList = ruleMatch![1];
// Each shape pypowsybl emits MUST be covered by the hide rule.
// `.nad-text-edges` is the connector-line group — without it the
// tick from each VL node to its hidden label box is left visible.
expect(selectorList).toMatch(/\.svg-container\.nad-hide-vl-labels\s+\.nad-text-edges\b/);
// Sanity: the previously-covered shapes must still be there.
expect(selectorList).toMatch(/\.svg-container\.nad-hide-vl-labels\s+\.nad-text-nodes\b/);
expect(selectorList).toMatch(/\.svg-container\.nad-hide-vl-labels\s+\.nad-label-box\b/);
});
it('handles empty string placeholder followed by real SVG without erasing prior viewBox', () => {
// Simulates the full fix flow: component mounts with empty svg,
// then receives real svg — only one injection, container ready.
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { /* noop */ });
const containerRef = createRef<HTMLDivElement>();
// Step 1: mount empty (placeholder mode, n1Diagram null)
const { rerender } = render(
<MemoizedSvgContainer svg="" containerRef={containerRef} display="block" tabId="contingency" />
);
expect(containerRef.current?.querySelector('svg')).toBeNull();
// Step 2: fetchN1 completes, svg arrives
rerender(
<MemoizedSvgContainer svg='<svg viewBox="0 0 1000 1000"><g/></svg>' containerRef={containerRef} display="block" tabId="contingency" />
);
const svg = containerRef.current?.querySelector('svg');
expect(svg).toBeTruthy();
// Step 3: auto-zoom would set a new viewBox programmatically
svg!.setAttribute('viewBox', '100 100 50 50');
// Step 4: parent re-renders without svg change — must not reset viewBox
rerender(
<MemoizedSvgContainer svg='<svg viewBox="0 0 1000 1000"><g/></svg>' containerRef={containerRef} display="block" tabId="contingency" />
);
// The string svg prop creates a new reference each call, so this DOES
// re-inject — this test just documents that behavior. The fix in
// VisualizationPanel passes stable references via n1Diagram.svg.
consoleSpy.mockRestore();
});
});