// 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(); const { container } = render( ); 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(); const { container } = render( ); 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(); const svgString = ''; render( ); expect(containerRef.current?.innerHTML).toContain(' { const containerRef = createRef(); 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( ); 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 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(); const raw = ''; const { svg } = processSvg(raw, 100); expect(typeof svg).not.toBe('string'); // element rail engaged render( ); // 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(); const { container } = render( ); expect(container.querySelector('#action-svg-container')).toBeInTheDocument(); }); it('logs performance timing to console', () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { /* noop */ }); const containerRef = createRef(); render( ); 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(); render( ); // 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(); const { rerender } = render( ); // Initial render with empty svg: no injection expect(consoleSpy).not.toHaveBeenCalled(); // Transition to real SVG (simulates fetchN1 completing) rerender( ); // 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(' { const containerRef = createRef(); const svgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svgEl.setAttribute('viewBox', '0 0 100 100'); const { rerender } = render( ); // 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( ); 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(); const { container } = render( ); 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(); const { container } = render( ); 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(); // Step 1: mount empty (placeholder mode, n1Diagram null) const { rerender } = render( ); expect(containerRef.current?.querySelector('svg')).toBeNull(); // Step 2: fetchN1 completes, svg arrives rerender( ); 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( ); // 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(); }); });