// 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 { usePanZoom } from './usePanZoom'; import { setSmoothPanZoomMode } from '../utils/smoothPanZoom'; import type { ViewBox } from '../types'; /** * Tests for usePanZoom hook — focused on the useLayoutEffect optimizations: * - applyViewBox runs before paint when tab becomes active * - viewBox is applied to SVG DOM element directly * - setViewBox updates both ref and React state */ function createMockSvgContainer(viewBox?: string) { const container = document.createElement('div'); const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); if (viewBox) svg.setAttribute('viewBox', viewBox); container.appendChild(svg); return { container, svg }; } describe('usePanZoom', () => { const initialVB: ViewBox = { x: 0, y: 0, w: 1000, h: 800 }; beforeEach(() => { vi.clearAllMocks(); }); it('returns null viewBox when initialViewBox is null', () => { const ref = { current: document.createElement('div') }; const { result } = renderHook(() => usePanZoom(ref, null, true)); expect(result.current.viewBox).toBeNull(); }); it('syncs viewBox from initialViewBox on mount', () => { const { container, svg } = createMockSvgContainer('0 0 500 500'); const ref = { current: container }; const { result } = renderHook(() => usePanZoom(ref, initialVB, true)); expect(result.current.viewBox).toEqual(initialVB); expect(svg.getAttribute('viewBox')).toBe('0 0 1000 800'); }); it('applies viewBox to SVG DOM when becoming active (useLayoutEffect)', () => { const { container, svg } = createMockSvgContainer('0 0 500 500'); const ref = { current: container }; // Start inactive const { result, rerender } = renderHook( ({ active }) => usePanZoom(ref, initialVB, active), { initialProps: { active: false } } ); // Initial viewBox is set even when inactive (via initialViewBox effect) expect(result.current.viewBox).toEqual(initialVB); // Now become active — useLayoutEffect should apply viewBox before paint rerender({ active: true }); expect(svg.getAttribute('viewBox')).toBe('0 0 1000 800'); }); it('setViewBox updates both DOM and React state', () => { const { container, svg } = createMockSvgContainer('0 0 500 500'); const ref = { current: container }; const { result } = renderHook(() => usePanZoom(ref, initialVB, true)); const newVB: ViewBox = { x: 100, y: 200, w: 500, h: 400 }; act(() => { result.current.setViewBox(newVB); }); expect(result.current.viewBox).toEqual(newVB); expect(svg.getAttribute('viewBox')).toBe('100 200 500 400'); }); it('preserves viewBox across active/inactive transitions', () => { const { container, svg } = createMockSvgContainer('0 0 500 500'); const ref = { current: container }; const { result, rerender } = renderHook( ({ active }) => usePanZoom(ref, initialVB, active), { initialProps: { active: true } } ); // Set a custom viewBox const customVB: ViewBox = { x: 50, y: 50, w: 300, h: 200 }; act(() => { result.current.setViewBox(customVB); }); expect(svg.getAttribute('viewBox')).toBe('50 50 300 200'); // Deactivate rerender({ active: false }); // Reactivate — should restore the custom viewBox rerender({ active: true }); expect(svg.getAttribute('viewBox')).toBe('50 50 300 200'); }); it('updates viewBox when initialViewBox changes (new diagram loaded)', () => { const { container, svg } = createMockSvgContainer('0 0 500 500'); const ref = { current: container }; const { result, rerender } = renderHook( ({ ivb }) => usePanZoom(ref, ivb, true), { initialProps: { ivb: initialVB as ViewBox | null } } ); expect(result.current.viewBox).toEqual(initialVB); const newIVB: ViewBox = { x: 0, y: 0, w: 2000, h: 1600 }; rerender({ ivb: newIVB }); expect(result.current.viewBox).toEqual(newIVB); expect(svg.getAttribute('viewBox')).toBe('0 0 2000 1600'); }); it('does not crash when svgRef is null', () => { const ref = { current: null }; const { result } = renderHook(() => usePanZoom(ref, initialVB, true)); expect(result.current.viewBox).toEqual(initialVB); }); describe('svg-interacting class toggle', () => { it('does not have svg-interacting class initially', () => { const { container } = createMockSvgContainer('0 0 1000 800'); const ref = { current: container }; renderHook(() => usePanZoom(ref, initialVB, true)); expect(container.classList.contains('svg-interacting')).toBe(false); }); }); describe('CTM cache invalidation', () => { it('setViewBox applies viewBox to DOM (which invalidates cached CTM)', () => { const { container, svg } = createMockSvgContainer('0 0 1000 800'); const ref = { current: container }; const { result } = renderHook(() => usePanZoom(ref, initialVB, true)); // Simulate zoom by setting a smaller viewBox const zoomedVB: ViewBox = { x: 200, y: 200, w: 400, h: 300 }; act(() => { result.current.setViewBox(zoomedVB); }); // viewBox should update in DOM expect(svg.getAttribute('viewBox')).toBe('200 200 400 300'); // Set another viewBox — each call invalidates cached CTM const zoomedVB2: ViewBox = { x: 300, y: 300, w: 200, h: 150 }; act(() => { result.current.setViewBox(zoomedVB2); }); expect(svg.getAttribute('viewBox')).toBe('300 300 200 150'); expect(result.current.viewBox).toEqual(zoomedVB2); }); }); describe('regression: multiple rapid active/inactive transitions', () => { it('does not corrupt viewBox after rapid tab switching', () => { const { container, svg } = createMockSvgContainer('0 0 500 500'); const ref = { current: container }; const { result, rerender } = renderHook( ({ active }) => usePanZoom(ref, initialVB, active), { initialProps: { active: true } } ); // Set a custom zoom const zoomedVB: ViewBox = { x: 100, y: 100, w: 200, h: 200 }; act(() => { result.current.setViewBox(zoomedVB); }); // Rapid tab switch simulation rerender({ active: false }); rerender({ active: true }); rerender({ active: false }); rerender({ active: true }); // ViewBox should be preserved through all transitions expect(svg.getAttribute('viewBox')).toBe('100 100 200 200'); expect(result.current.viewBox).toEqual(zoomedVB); }); }); describe('zoom tier (data-zoom-tier attribute)', () => { it('sets "overview" tier on initial load (full extent viewBox)', () => { const { container } = createMockSvgContainer('0 0 500 500'); const ref = { current: container }; renderHook(() => usePanZoom(ref, initialVB, true)); // ratio = 1000/1000 = 1.0 > 0.5 → overview expect(container.getAttribute('data-zoom-tier')).toBe('overview'); }); it('sets "region" tier when zoomed to 50% of original width', () => { const { container } = createMockSvgContainer('0 0 500 500'); const ref = { current: container }; const { result } = renderHook(() => usePanZoom(ref, initialVB, true)); // ratio = 400/1000 = 0.4 → region (0.15 < 0.4 ≤ 0.5) act(() => { result.current.setViewBox({ x: 200, y: 200, w: 400, h: 300 }); }); expect(container.getAttribute('data-zoom-tier')).toBe('region'); }); it('sets "detail" tier when zoomed to <15% of original width', () => { const { container } = createMockSvgContainer('0 0 500 500'); const ref = { current: container }; const { result } = renderHook(() => usePanZoom(ref, initialVB, true)); // ratio = 100/1000 = 0.1 → detail (≤ 0.15) act(() => { result.current.setViewBox({ x: 400, y: 400, w: 100, h: 80 }); }); expect(container.getAttribute('data-zoom-tier')).toBe('detail'); }); it('transitions between tiers as zoom changes', () => { const { container } = createMockSvgContainer('0 0 500 500'); const ref = { current: container }; const { result } = renderHook(() => usePanZoom(ref, initialVB, true)); expect(container.getAttribute('data-zoom-tier')).toBe('overview'); // Zoom to region act(() => { result.current.setViewBox({ x: 0, y: 0, w: 300, h: 240 }); }); expect(container.getAttribute('data-zoom-tier')).toBe('region'); // Zoom to detail act(() => { result.current.setViewBox({ x: 0, y: 0, w: 100, h: 80 }); }); expect(container.getAttribute('data-zoom-tier')).toBe('detail'); // Zoom back out to overview act(() => { result.current.setViewBox({ x: 0, y: 0, w: 900, h: 720 }); }); expect(container.getAttribute('data-zoom-tier')).toBe('overview'); }); it('resets tier when a new diagram loads (initialViewBox changes)', () => { const { container } = createMockSvgContainer('0 0 500 500'); const ref = { current: container }; const { result, rerender } = renderHook( ({ ivb }) => usePanZoom(ref, ivb, true), { initialProps: { ivb: initialVB as ViewBox | null } } ); // Zoom into detail act(() => { result.current.setViewBox({ x: 400, y: 400, w: 100, h: 80 }); }); expect(container.getAttribute('data-zoom-tier')).toBe('detail'); // New diagram loads with different dimensions const newIVB: ViewBox = { x: 0, y: 0, w: 5000, h: 4000 }; rerender({ ivb: newIVB }); // Should reset to overview (ratio = 5000/5000 = 1.0) expect(container.getAttribute('data-zoom-tier')).toBe('overview'); }); it('preserves tier across active/inactive transitions', () => { const { container } = createMockSvgContainer('0 0 500 500'); const ref = { current: container }; const { result, rerender } = renderHook( ({ active }) => usePanZoom(ref, initialVB, active), { initialProps: { active: true } } ); // Zoom to detail act(() => { result.current.setViewBox({ x: 0, y: 0, w: 100, h: 80 }); }); expect(container.getAttribute('data-zoom-tier')).toBe('detail'); // Deactivate and reactivate rerender({ active: false }); rerender({ active: true }); expect(container.getAttribute('data-zoom-tier')).toBe('detail'); }); it('does not set tier when container ref is null', () => { const ref = { current: null }; renderHook(() => usePanZoom(ref, initialVB, true)); // No container → no attribute to check (should not crash) }); it('handles exact boundary values correctly', () => { const { container } = createMockSvgContainer('0 0 500 500'); const ref = { current: container }; const { result } = renderHook(() => usePanZoom(ref, initialVB, true)); // ratio = 500/1000 = 0.5 → exactly at boundary → region (not overview) act(() => { result.current.setViewBox({ x: 0, y: 0, w: 500, h: 400 }); }); expect(container.getAttribute('data-zoom-tier')).toBe('region'); // ratio = 150/1000 = 0.15 → exactly at boundary → detail (not region) act(() => { result.current.setViewBox({ x: 0, y: 0, w: 150, h: 120 }); }); expect(container.getAttribute('data-zoom-tier')).toBe('detail'); }); }); // Regression for Bug 1/2/4 in docs/features/detachable-viz-tabs.md: // `usePanZoom` used to bind mousemove/mouseup to `window` captured // at effect-run time. When a tab was detached into a popup, the // element's ownerWindow became the popup but the listeners still // lived on the main window — drag-pan therefore stopped working // inside the popup. The fix binds mousemove/mouseup PER-DRAG // inside handleMouseDown, using a fresh // `el.ownerDocument.defaultView` lookup each time. This test // verifies that drag-start binds to the element's current owner // window and drag-end unbinds from it, regardless of the window // identity. describe('drag listeners bind to the element\'s current ownerWindow per-drag (Bug 1/2/4)', () => { it('does not register any mousemove/mouseup on window at effect bind time', () => { const { container } = createMockSvgContainer('0 0 1000 800'); const ref = { current: container }; const windowAddSpy = vi.spyOn(window, 'addEventListener'); renderHook(() => usePanZoom(ref, initialVB, true)); // Critical: the effect must NOT register any // mousemove/mouseup globally at bind time. Those are only // registered when a drag actually starts (in handleMouseDown). const globalMouseMoveCalls = windowAddSpy.mock.calls.filter( ([evt]) => evt === 'mousemove' || evt === 'mouseup' ); expect(globalMouseMoveCalls).toEqual([]); windowAddSpy.mockRestore(); }); it('binds mousemove/mouseup on the element\'s current ownerWindow when mousedown fires', () => { const { container } = createMockSvgContainer('0 0 1000 800'); const ref = { current: container }; renderHook(() => usePanZoom(ref, initialVB, true)); // `container.ownerDocument.defaultView` is the jsdom // window here. Spy on addEventListener on that window. const ownerWindow = container.ownerDocument.defaultView!; const addSpy = vi.spyOn(ownerWindow, 'addEventListener'); // Fire a mousedown on the element to enter drag state. const mouseDown = new MouseEvent('mousedown', { bubbles: true, clientX: 10, clientY: 10 }); container.dispatchEvent(mouseDown); // The drag listeners are now bound on the owner window. const calls = addSpy.mock.calls.map(([evt]) => evt); expect(calls).toContain('mousemove'); expect(calls).toContain('mouseup'); addSpy.mockRestore(); }); it('unbinds mousemove/mouseup from the owner window on mouseup', () => { const { container } = createMockSvgContainer('0 0 1000 800'); const ref = { current: container }; renderHook(() => usePanZoom(ref, initialVB, true)); const ownerWindow = container.ownerDocument.defaultView!; const removeSpy = vi.spyOn(ownerWindow, 'removeEventListener'); container.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: 10, clientY: 10 })); ownerWindow.dispatchEvent(new MouseEvent('mouseup')); const calls = removeSpy.mock.calls.map(([evt]) => evt); expect(calls).toContain('mousemove'); expect(calls).toContain('mouseup'); removeSpy.mockRestore(); }); it('resolves the ownerWindow on every mousedown, even across container movements', () => { // Use a detached container so we can freely patch its // ownerDocument.defaultView without affecting other tests. const doc = document.implementation.createHTMLDocument('t'); const container = doc.createElement('div'); const svg = doc.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('viewBox', '0 0 1000 800'); container.appendChild(svg); const ref = { current: container }; renderHook(() => usePanZoom(ref, initialVB, true)); // Simulate a "second window" by patching the detached // document's defaultView to point at a fake window. This // is what would happen when the tab is relocated to a // popup via imperative DOM move. let lastTarget: EventTarget | null = null; const fakeWindow: Partial = { addEventListener: ((event: string) => { lastTarget = fakeWindow as unknown as EventTarget; void event; }) as Window['addEventListener'], removeEventListener: (() => {}) as Window['removeEventListener'], }; Object.defineProperty(doc, 'defaultView', { configurable: true, get: () => fakeWindow, }); container.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: 10, clientY: 10 })); // On this drag, listeners were bound to the fake window — // proving that the lookup happens per-drag rather than // once at effect time. (If it were captured at effect // time, the drag would have bound to whatever `window` // was at that moment, not the current ownerWindow.) expect(lastTarget).toBe(fakeWindow); }); }); describe('bitmap mode — graceful degradation', () => { // In jsdom (no SVG-to-canvas raster) and on a cold serialisation cache, // a bitmap-mode gesture must NOT crash, must NOT mount a canvas overlay, // and must leave the live SVG visible (it stays on the viewBox fallback). // The expensive serialize is never run synchronously in the handler, so // the gesture start stays responsive. it('does not crash or mount a canvas; keeps the live SVG visible', async () => { setSmoothPanZoomMode('bitmap'); try { const { container, svg } = createMockSvgContainer('0 0 1000 800'); document.body.appendChild(container); const ref = { current: container }; renderHook(() => usePanZoom(ref, initialVB, true)); act(() => { container.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: 100, clientY: 100 })); }); act(() => { window.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX: 130, clientY: 120 })); }); // flush the deferred build (setTimeout 0) await act(async () => { await new Promise((r) => setTimeout(r, 5)); }); expect(container.querySelector('canvas')).toBeNull(); expect(svg.style.visibility).not.toBe('hidden'); act(() => { window.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); }); document.body.removeChild(container); } finally { setSmoothPanZoomMode('off'); } }); }); });