/** * Tests for canvas controls (pan, zoom, rotate, reset, fit) * Tests viewport store and control functions */ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { get } from 'svelte/store'; import { viewport, isViewportDefault, isZoomMin, isZoomMax } from '$lib/stores/viewport'; import { panTo, panBy } from '$lib/canvas/controls/pan'; import { zoomTo, zoomBy, zoomIn, zoomOut } from '$lib/canvas/controls/zoom'; import { rotateTo, rotateBy, rotateClockwise, rotateCounterClockwise, resetRotation, rotateTo90, rotateTo180, rotateTo270, } from '$lib/canvas/controls/rotate'; import { resetCamera, resetPan, resetZoom } from '$lib/canvas/controls/reset'; describe('Viewport Store', () => { beforeEach(() => { // Reset viewport to default state before each test viewport.reset(); }); describe('Initialization', () => { it('starts with default values', () => { const state = get(viewport); expect(state).toEqual({ x: 0, y: 0, zoom: 1.0, rotation: 0, }); }); it('isViewportDefault is true at initialization', () => { expect(get(isViewportDefault)).toBe(true); }); it('provides viewport bounds', () => { const bounds = viewport.getBounds(); expect(bounds).toEqual({ minZoom: 0.1, maxZoom: 5.0, minRotation: 0, maxRotation: 360, }); }); }); describe('Pan Operations', () => { it('sets pan position', () => { viewport.setPan(100, 200); const state = get(viewport); expect(state.x).toBe(100); expect(state.y).toBe(200); }); it('pans by delta', () => { viewport.setPan(50, 50); viewport.panBy(25, 30); const state = get(viewport); expect(state.x).toBe(75); expect(state.y).toBe(80); }); it('allows negative pan values', () => { viewport.setPan(-100, -200); const state = get(viewport); expect(state.x).toBe(-100); expect(state.y).toBe(-200); }); it('handles large pan values', () => { viewport.setPan(100000, 100000); const state = get(viewport); expect(state.x).toBe(100000); expect(state.y).toBe(100000); }); }); describe('Zoom Operations', () => { it('sets zoom level', () => { viewport.setZoom(2.0); const state = get(viewport); expect(state.zoom).toBe(2.0); }); it('clamps zoom to minimum', () => { viewport.setZoom(0.05); const state = get(viewport); expect(state.zoom).toBe(0.1); }); it('clamps zoom to maximum', () => { viewport.setZoom(10.0); const state = get(viewport); expect(state.zoom).toBe(5.0); }); it('zooms by factor', () => { viewport.setZoom(1.0); viewport.zoomBy(2.0); const state = get(viewport); expect(state.zoom).toBe(2.0); }); it('zooms to center point', () => { viewport.setZoom(1.0, 100, 100); const state = get(viewport); expect(state.zoom).toBe(1.0); // Position should remain at center }); it('isZoomMin reflects minimum zoom', () => { viewport.setZoom(0.1); expect(get(isZoomMin)).toBe(true); viewport.setZoom(1.0); expect(get(isZoomMin)).toBe(false); }); it('isZoomMax reflects maximum zoom', () => { viewport.setZoom(5.0); expect(get(isZoomMax)).toBe(true); viewport.setZoom(1.0); expect(get(isZoomMax)).toBe(false); }); }); describe('Rotation Operations', () => { it('sets rotation', () => { viewport.setRotation(45); const state = get(viewport); expect(state.rotation).toBe(45); }); it('normalizes rotation to 0-360', () => { viewport.setRotation(450); expect(get(viewport).rotation).toBe(90); viewport.setRotation(-90); expect(get(viewport).rotation).toBe(270); }); it('rotates by delta', () => { viewport.setRotation(45); viewport.rotateBy(15); expect(get(viewport).rotation).toBe(60); }); it('handles negative rotation delta', () => { viewport.setRotation(45); viewport.rotateBy(-15); expect(get(viewport).rotation).toBe(30); }); it('wraps rotation around 360', () => { viewport.setRotation(350); viewport.rotateBy(20); expect(get(viewport).rotation).toBe(10); }); }); describe('Reset Operations', () => { it('resets viewport to default', () => { viewport.setPan(100, 100); viewport.setZoom(2.0); viewport.setRotation(45); viewport.reset(); const state = get(viewport); expect(state).toEqual({ x: 0, y: 0, zoom: 1.0, rotation: 0, }); }); it('reset makes isViewportDefault true', () => { viewport.setPan(100, 100); expect(get(isViewportDefault)).toBe(false); viewport.reset(); expect(get(isViewportDefault)).toBe(true); }); }); describe('Fit to Screen', () => { it('fits content to screen with default padding', () => { viewport.fitToScreen(800, 600, 1024, 768); const state = get(viewport); expect(state.zoom).toBeGreaterThan(0); expect(state.rotation).toBe(0); // Rotation reset when fitting }); it('fits content with custom padding', () => { viewport.fitToScreen(800, 600, 1024, 768, 100); const state = get(viewport); expect(state.zoom).toBeGreaterThan(0); }); it('handles oversized content', () => { viewport.fitToScreen(2000, 1500, 1024, 768); const state = get(viewport); expect(state.zoom).toBeLessThan(1.0); }); it('handles undersized content', () => { viewport.fitToScreen(100, 100, 1024, 768); const state = get(viewport); expect(state.zoom).toBeGreaterThan(1.0); }); it('respects maximum zoom when fitting', () => { // Very small content that would zoom beyond max viewport.fitToScreen(10, 10, 1024, 768); const state = get(viewport); expect(state.zoom).toBeLessThanOrEqual(5.0); }); }); describe('Load State', () => { it('loads partial state', () => { viewport.loadState({ x: 100, y: 200 }); const state = get(viewport); expect(state.x).toBe(100); expect(state.y).toBe(200); expect(state.zoom).toBe(1.0); // Unchanged expect(state.rotation).toBe(0); // Unchanged }); it('loads complete state', () => { viewport.loadState({ x: 100, y: 200, zoom: 2.5, rotation: 90, }); const state = get(viewport); expect(state).toEqual({ x: 100, y: 200, zoom: 2.5, rotation: 90, }); }); it('clamps loaded zoom to bounds', () => { viewport.loadState({ zoom: 10.0 }); expect(get(viewport).zoom).toBe(5.0); viewport.loadState({ zoom: 0.01 }); expect(get(viewport).zoom).toBe(0.1); }); it('normalizes loaded rotation', () => { viewport.loadState({ rotation: 450 }); expect(get(viewport).rotation).toBe(90); viewport.loadState({ rotation: -45 }); expect(get(viewport).rotation).toBe(315); }); }); describe('State Subscription', () => { it('notifies subscribers on pan changes', () => { const subscriber = vi.fn(); const unsubscribe = viewport.subscribe(subscriber); viewport.setPan(100, 100); expect(subscriber).toHaveBeenCalled(); unsubscribe(); }); it('notifies subscribers on zoom changes', () => { const subscriber = vi.fn(); const unsubscribe = viewport.subscribe(subscriber); viewport.setZoom(2.0); expect(subscriber).toHaveBeenCalled(); unsubscribe(); }); it('notifies subscribers on rotation changes', () => { const subscriber = vi.fn(); const unsubscribe = viewport.subscribe(subscriber); viewport.setRotation(45); expect(subscriber).toHaveBeenCalled(); unsubscribe(); }); }); }); describe('Pan Controls', () => { beforeEach(() => { viewport.reset(); }); describe('Programmatic Pan', () => { it('panTo sets absolute position', () => { panTo(100, 200); const state = get(viewport); expect(state.x).toBe(100); expect(state.y).toBe(200); }); it('panBy moves relative to current position', () => { panTo(50, 50); panBy(25, 30); const state = get(viewport); expect(state.x).toBe(75); expect(state.y).toBe(80); }); it('panBy with negative deltas', () => { panTo(100, 100); panBy(-50, -50); const state = get(viewport); expect(state.x).toBe(50); expect(state.y).toBe(50); }); }); }); describe('Zoom Controls', () => { beforeEach(() => { viewport.reset(); }); describe('Programmatic Zoom', () => { it('zoomTo sets absolute zoom level', () => { zoomTo(2.5); expect(get(viewport).zoom).toBe(2.5); }); it('zoomBy multiplies current zoom', () => { zoomTo(2.0); zoomBy(1.5); expect(get(viewport).zoom).toBe(3.0); }); it('zoomIn increases zoom', () => { const initialZoom = get(viewport).zoom; zoomIn(); expect(get(viewport).zoom).toBeGreaterThan(initialZoom); }); it('zoomOut decreases zoom', () => { zoomTo(2.0); const initialZoom = get(viewport).zoom; zoomOut(); expect(get(viewport).zoom).toBeLessThan(initialZoom); }); it('zoomIn respects maximum zoom', () => { zoomTo(4.9); zoomIn(); expect(get(viewport).zoom).toBeLessThanOrEqual(5.0); }); it('zoomOut respects minimum zoom', () => { zoomTo(0.15); zoomOut(); expect(get(viewport).zoom).toBeGreaterThanOrEqual(0.1); }); }); }); describe('Rotate Controls', () => { beforeEach(() => { viewport.reset(); }); describe('Programmatic Rotation', () => { it('rotateTo sets absolute rotation', () => { rotateTo(90); expect(get(viewport).rotation).toBe(90); }); it('rotateBy adds to current rotation', () => { rotateTo(45); rotateBy(15); expect(get(viewport).rotation).toBe(60); }); it('rotateClockwise rotates by step', () => { rotateClockwise(); // Default step is 15 degrees expect(get(viewport).rotation).toBe(15); }); it('rotateCounterClockwise rotates by negative step', () => { rotateTo(30); rotateCounterClockwise(); // Default step is 15 degrees expect(get(viewport).rotation).toBe(15); }); it('resetRotation sets to 0', () => { rotateTo(90); resetRotation(); expect(get(viewport).rotation).toBe(0); }); it('rotateTo90 sets to 90 degrees', () => { rotateTo90(); expect(get(viewport).rotation).toBe(90); }); it('rotateTo180 sets to 180 degrees', () => { rotateTo180(); expect(get(viewport).rotation).toBe(180); }); it('rotateTo270 sets to 270 degrees', () => { rotateTo270(); expect(get(viewport).rotation).toBe(270); }); }); }); describe('Reset Controls', () => { beforeEach(() => { // Set non-default values viewport.setPan(100, 200); viewport.setZoom(2.5); viewport.setRotation(90); }); describe('Selective Reset', () => { it('resetPan only resets position', () => { resetPan(); const state = get(viewport); expect(state.x).toBe(0); expect(state.y).toBe(0); expect(state.zoom).toBe(2.5); // Unchanged expect(state.rotation).toBe(90); // Unchanged }); it('resetZoom only resets zoom', () => { resetZoom(); const state = get(viewport); expect(state.x).toBe(100); // Unchanged expect(state.y).toBe(200); // Unchanged expect(state.zoom).toBe(1.0); expect(state.rotation).toBe(90); // Unchanged }); it('resetRotation (from reset controls) only resets rotation', () => { resetRotation(); const state = get(viewport); expect(state.x).toBe(100); // Unchanged expect(state.y).toBe(200); // Unchanged expect(state.zoom).toBe(2.5); // Unchanged expect(state.rotation).toBe(0); }); it('resetCamera resets everything', () => { resetCamera(); const state = get(viewport); expect(state).toEqual({ x: 0, y: 0, zoom: 1.0, rotation: 0, }); }); }); }); describe('Viewport State Serialization', () => { beforeEach(() => { viewport.reset(); }); it('serializes viewport state to JSON', async () => { const { serializeViewportState } = await import('$lib/stores/viewport'); viewport.setPan(100, 200); viewport.setZoom(2.0); viewport.setRotation(45); const state = get(viewport); const serialized = serializeViewportState(state); expect(serialized).toBe(JSON.stringify({ x: 100, y: 200, zoom: 2, rotation: 45 })); }); it('deserializes viewport state from JSON', async () => { const { deserializeViewportState } = await import('$lib/stores/viewport'); const json = JSON.stringify({ x: 100, y: 200, zoom: 2.5, rotation: 90 }); const state = deserializeViewportState(json); expect(state).toEqual({ x: 100, y: 200, zoom: 2.5, rotation: 90, }); }); it('handles invalid JSON gracefully', async () => { const { deserializeViewportState } = await import('$lib/stores/viewport'); const state = deserializeViewportState('invalid json'); // Should return default state expect(state).toEqual({ x: 0, y: 0, zoom: 1.0, rotation: 0, }); }); it('validates deserialized values', async () => { const { deserializeViewportState } = await import('$lib/stores/viewport'); const json = JSON.stringify({ x: 100, y: 200, zoom: 10.0, rotation: 450 }); const state = deserializeViewportState(json); // Zoom should be clamped to max expect(state.zoom).toBe(5.0); // Rotation should be normalized to 0-360 expect(state.rotation).toBe(90); }); it('handles missing fields in JSON', async () => { const { deserializeViewportState } = await import('$lib/stores/viewport'); const json = JSON.stringify({ x: 100 }); const state = deserializeViewportState(json); expect(state.x).toBe(100); expect(state.y).toBe(0); // Default expect(state.zoom).toBe(1.0); // Default expect(state.rotation).toBe(0); // Default }); }); describe('Integration Tests', () => { beforeEach(() => { viewport.reset(); }); it('complex viewport manipulation sequence', () => { // Pan viewport.setPan(100, 100); // Zoom viewport.setZoom(2.0); // Rotate viewport.setRotation(45); // Pan by delta viewport.panBy(50, 50); const state = get(viewport); expect(state.x).toBe(150); expect(state.y).toBe(150); expect(state.zoom).toBe(2.0); expect(state.rotation).toBe(45); }); it('reset after complex manipulation', () => { viewport.setPan(100, 100); viewport.setZoom(3.0); viewport.setRotation(180); viewport.reset(); expect(get(isViewportDefault)).toBe(true); }); it('multiple zoom operations maintain center', () => { viewport.setZoom(2.0, 500, 500); viewport.setZoom(1.5, 500, 500); // Position should adjust to keep point at 500,500 centered const state = get(viewport); expect(state.zoom).toBe(1.5); }); });