Files
webref/frontend/tests/canvas/controls.test.ts
Danilo Reyes 3700ba02ea phase 6
2025-11-02 14:03:01 -06:00

628 lines
15 KiB
TypeScript

/**
* 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);
});
});