import { describe, it, expect } from 'vitest'; import { clampZoom, parsePanZoomParams, serializePanZoomParams, screenDeltaToSvg, zoomAtPoint, recentreOn, clampPan, DEFAULT_VIEW, DEFAULT_ZOOM, LEGIBLE_ZOOM, MIN_ZOOM, MAX_ZOOM } from './panZoom'; describe('clampZoom', () => { it('returns the value unchanged when within range', () => { expect(clampZoom(1)).toBe(1); expect(clampZoom(0.5)).toBe(0.5); expect(clampZoom(2.75)).toBe(2.75); }); it('clamps below MIN_ZOOM up to MIN_ZOOM', () => { expect(clampZoom(0.1)).toBe(MIN_ZOOM); expect(clampZoom(0)).toBe(MIN_ZOOM); expect(clampZoom(-5)).toBe(MIN_ZOOM); }); it('clamps above MAX_ZOOM down to MAX_ZOOM', () => { expect(clampZoom(5)).toBe(MAX_ZOOM); expect(clampZoom(3.0001)).toBe(MAX_ZOOM); }); it('exposes the resolved zoom bounds', () => { expect(MIN_ZOOM).toBe(0.25); expect(MAX_ZOOM).toBe(3.0); }); }); describe('parsePanZoomParams', () => { it('parses well-formed cx/cy/z params', () => { expect(parsePanZoomParams({ cx: '120', cy: '-40', z: '1.5' })).toEqual({ x: 120, y: -40, z: 1.5 }); }); it('falls back to DEFAULT_VIEW when params are absent', () => { expect(parsePanZoomParams({})).toEqual(DEFAULT_VIEW); expect(DEFAULT_VIEW).toEqual({ x: 0, y: 0, z: DEFAULT_ZOOM }); }); it('rejects Infinity and NaN, degrading each axis to its default (Nora #692)', () => { expect(parsePanZoomParams({ z: 'Infinity' }).z).toBe(DEFAULT_ZOOM); expect(parsePanZoomParams({ z: 'NaN' }).z).toBe(DEFAULT_ZOOM); expect(parsePanZoomParams({ cx: 'NaN', cy: 'Infinity' })).toEqual(DEFAULT_VIEW); expect(parsePanZoomParams({ cx: '1e500' }).x).toBe(0); }); it('clamps an out-of-range zoom into the supported bounds', () => { expect(parsePanZoomParams({ z: '99' }).z).toBe(MAX_ZOOM); expect(parsePanZoomParams({ z: '0.01' }).z).toBe(MIN_ZOOM); expect(parsePanZoomParams({ z: '-3' }).z).toBe(MIN_ZOOM); }); }); describe('serializePanZoomParams', () => { it('produces string cx/cy/z keys', () => { expect(serializePanZoomParams({ x: 120, y: -40, z: 1.5 })).toEqual({ cx: '120', cy: '-40', z: '1.5' }); }); it('round-trips through parsePanZoomParams', () => { const state = { x: 87.5, y: -12.25, z: 2.4 }; expect(parsePanZoomParams(serializePanZoomParams(state))).toEqual(state); }); }); describe('screenDeltaToSvg', () => { it('scales a pixel delta by the viewBox-to-element ratio per axis', () => { // viewBox is 2x the element in width, 2x in height → 1px == 2 SVG units. expect(screenDeltaToSvg(10, 5, 1000, 800, 500, 400)).toEqual({ dx: 20, dy: 10 }); }); it('is identity when the viewBox matches the element pixel size', () => { expect(screenDeltaToSvg(7, -3, 600, 600, 600, 600)).toEqual({ dx: 7, dy: -3 }); }); it('returns zero when the element has no measured size', () => { expect(screenDeltaToSvg(10, 10, 1000, 800, 0, 0)).toEqual({ dx: 0, dy: 0 }); }); }); describe('zoomAtPoint', () => { // The anchor is expressed as an offset (in SVG units) from the base viewBox // centre. The fraction of the anchor across the viewBox must not change. const anchorScreenFraction = (state: { x: number; z: number }, anchorOffsetX: number) => { const baseW = 1000; const w = baseW / state.z; const centreOffset = anchorOffsetX - state.x; // anchor relative to viewBox centre return centreOffset / w + 0.5; }; it('keeps the canvas centre fixed when the anchor is the centre', () => { const next = zoomAtPoint({ x: 0, y: 0, z: 1 }, 2, 0, 0); expect(next).toEqual({ x: 0, y: 0, z: 2 }); }); it('keeps an off-centre anchor at the same screen position across a zoom-in', () => { const before = { x: 0, y: 0, z: 1 }; const after = zoomAtPoint(before, 2, 100, 50); expect(after.z).toBe(2); expect(anchorScreenFraction(after, 100)).toBeCloseTo(anchorScreenFraction(before, 100), 10); }); it('clamps the target zoom and makes no move when already at the bound', () => { const next = zoomAtPoint({ x: 30, y: 10, z: MAX_ZOOM }, 99, 200, 200); expect(next).toEqual({ x: 30, y: 10, z: MAX_ZOOM }); }); }); describe('recentreOn', () => { const node = { x: 300, y: 200 }; const base = { x: 100, y: 100 }; it('pans so the node sits at the viewBox centre, keeping the current zoom', () => { expect(recentreOn(node, base, { x: 0, y: 0, z: 1 }, false)).toEqual({ x: 200, y: 100, z: 1 }); }); it('auto-zooms up to LEGIBLE_ZOOM when zoomed out (OQ-005)', () => { const next = recentreOn(node, base, { x: 0, y: 0, z: 0.4 }, true); expect(next.z).toBe(LEGIBLE_ZOOM); expect({ x: next.x, y: next.y }).toEqual({ x: 200, y: 100 }); }); it('does not reduce an already-legible zoom when auto-zooming', () => { expect(recentreOn(node, base, { x: 0, y: 0, z: 2 }, true).z).toBe(2); }); it('leaves zoom untouched when auto-zoom is off', () => { expect(recentreOn(node, base, { x: 0, y: 0, z: 0.4 }, false).z).toBe(0.4); }); }); describe('clampPan', () => { // Base frame is 1000 x 800. it('forbids panning when the whole tree fits (z <= 1)', () => { expect(clampPan({ x: 200, y: -100, z: 1 }, 1000, 800)).toEqual({ x: 0, y: 0, z: 1 }); expect(clampPan({ x: 50, y: 50, z: 0.5 }, 1000, 800)).toEqual({ x: 0, y: 0, z: 0.5 }); }); it('allows panning up to the edge when zoomed in (no infinite scroll)', () => { // At z=2 the viewBox is 500 wide → limit = (1000 - 500) / 2 = 250. expect(clampPan({ x: 1000, y: 0, z: 2 }, 1000, 800).x).toBe(250); expect(clampPan({ x: -1000, y: 0, z: 2 }, 1000, 800).x).toBe(-250); // Vertical limit at z=2: (800 - 400) / 2 = 200. expect(clampPan({ x: 0, y: 999, z: 2 }, 1000, 800).y).toBe(200); }); it('leaves an in-range pan untouched', () => { expect(clampPan({ x: 100, y: -50, z: 2 }, 1000, 800)).toEqual({ x: 100, y: -50, z: 2 }); }); });