Co-Study4Grid / frontend /src /hooks /usePanZoom.test.tsx
github-actions[bot]
Deploy f7b8bf6
1c730d1
Raw
History Blame Contribute Delete
20.5 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, 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<Window> = {
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');
}
});
});
});