/** * Integration Workflow Test — Full CAD workflow across ALL components. * * Tests the complete lifecycle: init → layers → elements → render → zoom/pan → * selection → grouping → snap → history → layer toggle → zoom fit → reset. */ import { describe, it, expect, beforeEach } from 'vitest'; import { RenderEngine } from '../src/canvas/RenderEngine'; import { SelectionEngine } from '../src/canvas/SelectionEngine'; import { ZoomPanController } from '../src/canvas/ZoomPanController'; import { SpatialIndex } from '../src/canvas/SpatialIndex'; import { LayerManager } from '../src/canvas/LayerManager'; import { SnapEngine } from '../src/canvas/SnapEngine'; import { HistoryManager } from '../src/history/HistoryManager'; import { GroupManager } from '../src/tools/modification/GroupTool'; import { CommandRegistry } from '../src/services/commandRegistry'; import type { CADElement, CADLayer, BlockDefinition } from '../src/types/cad.types'; import type { BackgroundConfig } from '../src/services/backgroundService'; // ─── Helpers ────────────────────────────────────────────────────────────────── function createMockCanvas(w = 800, h = 600): HTMLCanvasElement { const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; return canvas; } function makeLayer(id: string, overrides: Partial = {}): CADLayer { return { id, name: id, visible: true, locked: false, color: '#ffffff', lineType: 'solid' as const, transparency: 0, sortOrder: 0, parentId: null, ...overrides, }; } function makeLine( id: string, x1: number, y1: number, x2: number, y2: number, layerId = 'layer-default', ): CADElement { return { id, type: 'line', layerId, x: (x1 + x2) / 2, y: (y1 + y2) / 2, width: Math.abs(x2 - x1), height: Math.abs(y2 - y1), properties: { x1, y1, x2, y2, stroke: '#ffffff' }, }; } function makeRect( id: string, cx: number, cy: number, w: number, h: number, layerId = 'layer-default', ): CADElement { return { id, type: 'rect', layerId, x: cx, y: cy, width: w, height: h, properties: { stroke: '#ffffff' }, }; } function makeCircle( id: string, cx: number, cy: number, r: number, layerId = 'layer-default', ): CADElement { return { id, type: 'circle', layerId, x: cx, y: cy, width: r * 2, height: r * 2, properties: { radius: r, stroke: '#ffffff' }, }; } function makeSnapshot( elements: CADElement[], layers: CADLayer[], blocks: BlockDefinition[] = [], groups: ReturnType = [], bgConfig: BackgroundConfig | null = null, ) { return { elements, layers, blocks, groups, bgConfig }; } // ─── Integration Test ───────────────────────────────────────────────────────── describe('Integration Workflow — Full CAD Lifecycle', () => { // Component instances let canvas: HTMLCanvasElement; let layerManager: LayerManager; let spatialIndex: SpatialIndex; let zoomPan: ZoomPanController; let renderEngine: RenderEngine; let selectionEngine: SelectionEngine; let snapEngine: SnapEngine; let historyManager: HistoryManager; let groupManager: GroupManager; let commandRegistry: CommandRegistry; // Shared state let layers: CADLayer[]; let elements: CADElement[]; beforeEach(() => { canvas = createMockCanvas(800, 600); // Step 1: Initialize all components layerManager = new LayerManager(); spatialIndex = new SpatialIndex(); zoomPan = new ZoomPanController(canvas); renderEngine = new RenderEngine(canvas, zoomPan, spatialIndex, layerManager); selectionEngine = new SelectionEngine(renderEngine, spatialIndex, layerManager); snapEngine = new SnapEngine({ tolerance: 10 }); historyManager = new HistoryManager(); groupManager = new GroupManager(); commandRegistry = new CommandRegistry(); elements = []; layers = []; }); // ─── Step 1: Initialize all components ────────────────────────────────────── describe('Step 1: Component initialization', () => { it('should instantiate all components without errors', () => { expect(layerManager).toBeDefined(); expect(spatialIndex).toBeDefined(); expect(zoomPan).toBeDefined(); expect(renderEngine).toBeDefined(); expect(selectionEngine).toBeDefined(); expect(snapEngine).toBeDefined(); expect(historyManager).toBeDefined(); expect(groupManager).toBeDefined(); expect(commandRegistry).toBeDefined(); }); it('should have correct initial state', () => { expect(layerManager.getLayers()).toHaveLength(0); expect(layerManager.getActiveLayerId()).toBe(''); expect(zoomPan.getScale()).toBe(1); expect(selectionEngine.getSelectedIds().size).toBe(0); expect(groupManager.getGroups()).toHaveLength(0); expect(historyManager.getCurrentState()).toBeNull(); expect(historyManager.canUndo()).toBe(false); expect(historyManager.canRedo()).toBe(false); }); }); // ─── Step 2: Create layers, set visibility/lock ───────────────────────────── describe('Step 2: Layer management', () => { it('should create default and additional layers, set visibility/lock', () => { const defaultLayer = makeLayer('layer-default', { name: 'Default', sortOrder: 0 }); const archLayer = makeLayer('layer-arch', { name: 'Architecture', sortOrder: 1, color: '#ff0000' }); const hiddenLayer = makeLayer('layer-hidden', { name: 'Hidden', sortOrder: 2, visible: false }); const lockedLayer = makeLayer('layer-locked', { name: 'Locked', sortOrder: 3, locked: true }); layerManager.addLayer(defaultLayer); layerManager.addLayer(archLayer); layerManager.addLayer(hiddenLayer); layerManager.addLayer(lockedLayer); layers = layerManager.getLayers(); expect(layers).toHaveLength(4); expect(layers[0].id).toBe('layer-default'); // sortOrder 0 expect(layers[3].id).toBe('layer-locked'); // sortOrder 3 // Active layer should be first added expect(layerManager.getActiveLayerId()).toBe('layer-default'); // Set active layer layerManager.setActiveLayer('layer-arch'); expect(layerManager.getActiveLayerId()).toBe('layer-arch'); expect(layerManager.getActiveLayer()?.name).toBe('Architecture'); // Toggle visibility on arch layer layerManager.toggleVisibility('layer-arch'); expect(layerManager.getLayer('layer-arch')?.visible).toBe(false); // Toggle back layerManager.toggleVisibility('layer-arch'); expect(layerManager.getLayer('layer-arch')?.visible).toBe(true); // Toggle lock on default layer layerManager.toggleLock('layer-default'); expect(layerManager.isLocked('layer-default')).toBe(true); // getVisibleLayers returns visible && !locked const visible = layerManager.getVisibleLayers(); const visibleIds = visible.map(l => l.id); // layer-default: visible=true but locked=true → excluded // layer-arch: visible=true, locked=false → included // layer-hidden: visible=false → excluded // layer-locked: visible=true but locked=true → excluded expect(visibleIds).toContain('layer-arch'); expect(visibleIds).not.toContain('layer-default'); expect(visibleIds).not.toContain('layer-hidden'); expect(visibleIds).not.toContain('layer-locked'); // Unlock default for subsequent tests layerManager.toggleLock('layer-default'); expect(layerManager.isLocked('layer-default')).toBe(false); }); }); // ─── Step 3: Add elements to different layers, insert into SpatialIndex ────── describe('Step 3: Add elements to layers and SpatialIndex', () => { beforeEach(() => { layerManager.addLayer(makeLayer('layer-default', { sortOrder: 0 })); layerManager.addLayer(makeLayer('layer-arch', { sortOrder: 1, color: '#ff0000' })); }); it('should add line, rect, circle to different layers and insert into SpatialIndex', () => { const line1 = makeLine('el-line-1', 0, 0, 100, 100, 'layer-default'); const rect1 = makeRect('el-rect-1', 50, 50, 80, 60, 'layer-arch'); const circle1 = makeCircle('el-circle-1', 200, 200, 40, 'layer-default'); elements = [line1, rect1, circle1]; // Insert into spatial index for (const el of elements) { spatialIndex.insert(el); } // Verify spatial index search returns elements in viewport const viewport = { minX: -100, minY: -100, maxX: 300, maxY: 300 }; const found = spatialIndex.search(viewport); expect(found).toHaveLength(3); expect(found.map(e => e.id).sort()).toEqual(['el-circle-1', 'el-line-1', 'el-rect-1']); // Verify search with smaller viewport const smallVp = { minX: -10, minY: -10, maxX: 60, maxY: 60 }; const smallFound = spatialIndex.search(smallVp); // line1 bbox: minX=-50, minY=-50, maxX=150, maxY=150 → intersects // rect1 bbox: minX=10, minY=20, maxX=90, maxY=80 → intersects // circle1 bbox: minX=160, minY=160, maxX=240, maxY=240 → no expect(smallFound.map(e => e.id).sort()).toEqual(['el-line-1', 'el-rect-1']); }); }); // ─── Step 4: Render (verify no crash) ─────────────────────────────────────── describe('Step 4: Rendering', () => { beforeEach(() => { layerManager.addLayer(makeLayer('layer-default', { sortOrder: 0 })); layerManager.addLayer(makeLayer('layer-arch', { sortOrder: 1, color: '#ff0000' })); elements = [ makeLine('el-line-1', 0, 0, 100, 100, 'layer-default'), makeRect('el-rect-1', 50, 50, 80, 60, 'layer-arch'), makeCircle('el-circle-1', 200, 200, 40, 'layer-default'), ]; for (const el of elements) spatialIndex.insert(el); }); it('should render without crashing', () => { expect(() => renderEngine.render()).not.toThrow(); }); it('should render with grid enabled', () => { renderEngine.setOptions({ showGrid: true }); expect(() => renderEngine.render()).not.toThrow(); }); it('should render with grid disabled', () => { renderEngine.setOptions({ showGrid: false }); expect(() => renderEngine.render()).not.toThrow(); }); }); // ─── Step 5: Zoom/Pan ──────────────────────────────────────────────────────── describe('Step 5: Zoom and Pan', () => { it('should zoomAt, pan, and verify worldToScreen/screenToWorld roundtrip', () => { // Initial state expect(zoomPan.getScale()).toBe(1); const initialViewport = zoomPan.getViewport(); expect(initialViewport.minX).toBe(0); expect(initialViewport.minY).toBe(0); // Zoom in at center (400, 300) with factor 2 zoomPan.zoomAt(400, 300, 2); expect(zoomPan.getScale()).toBe(2); // After zoom, viewport should be smaller in world space const zoomedViewport = zoomPan.getViewport(); expect(zoomedViewport.maxX - zoomedViewport.minX).toBe(400); // 800/2 expect(zoomedViewport.maxY - zoomedViewport.minY).toBe(300); // 600/2 // Pan by (50, 30) — adds to offset set by zoomAt // zoomAt(400,300,2): offsetX = 400-(400-0)*2 = -400, offsetY = 300-(300-0)*2 = -300 zoomPan.pan(50, 30); const transform = zoomPan.getTransform(); expect(transform.e).toBe(-350); // -400 + 50 expect(transform.f).toBe(-270); // -300 + 30 // worldToScreen / screenToWorld roundtrip const worldPt = { x: 150, y: 75 }; const screenPt = zoomPan.worldToScreen(worldPt.x, worldPt.y); const backToWorld = zoomPan.screenToWorld(screenPt.x, screenPt.y); expect(backToWorld.x).toBeCloseTo(worldPt.x, 5); expect(backToWorld.y).toBeCloseTo(worldPt.y, 5); // Another roundtrip with different point const worldPt2 = { x: -50, y: 200 }; const screenPt2 = zoomPan.worldToScreen(worldPt2.x, worldPt2.y); const back2 = zoomPan.screenToWorld(screenPt2.x, screenPt2.y); expect(back2.x).toBeCloseTo(worldPt2.x, 5); expect(back2.y).toBeCloseTo(worldPt2.y, 5); }); }); // ─── Step 6: Selection ────────────────────────────────────────────────────── describe('Step 6: Selection (click and box)', () => { beforeEach(() => { layerManager.addLayer(makeLayer('layer-default', { sortOrder: 0 })); layerManager.addLayer(makeLayer('layer-arch', { sortOrder: 1, color: '#ff0000' })); elements = [ makeLine('el-line-1', 0, 0, 100, 100, 'layer-default'), makeRect('el-rect-1', 200, 200, 80, 60, 'layer-arch'), makeCircle('el-circle-1', 400, 400, 40, 'layer-default'), ]; for (const el of elements) spatialIndex.insert(el); }); it('should click-select a single element', () => { // Click near the line's midpoint (50, 50) const hit = selectionEngine.clickSelect(50, 50, elements); expect(hit).not.toBeNull(); expect(hit?.id).toBe('el-line-1'); const selectedIds = selectionEngine.getSelectedIds(); expect(selectedIds.size).toBe(1); expect(selectedIds.has('el-line-1')).toBe(true); }); it('should click-select the rect element', () => { // Click at rect center (200, 200) const hit = selectionEngine.clickSelect(200, 200, elements); expect(hit).not.toBeNull(); expect(hit?.id).toBe('el-rect-1'); expect(selectionEngine.getSelectedIds().has('el-rect-1')).toBe(true); }); it('should clear selection when clicking empty space', () => { // First select an element selectionEngine.clickSelect(50, 50, elements); expect(selectionEngine.getSelectedIds().size).toBe(1); // Click in empty area (far from elements) const hit = selectionEngine.clickSelect(1000, 1000, elements); expect(hit).toBeNull(); expect(selectionEngine.getSelectedIds().size).toBe(0); }); it('should box-select multiple elements (window selection)', () => { // Window selection: left-to-right drag enclosing line and rect // line bbox: minX=-50, minY=-50, maxX=150, maxY=150 // rect bbox: minX=160, minY=170, maxX=240, maxY=230 // Need a box that fully encloses both selectionEngine.startBoxSelect(-100, -100); selectionEngine.updateBoxSelect(300, 300, elements); const selected = selectionEngine.finishBoxSelect(elements); // Both line and rect should be fully enclosed expect(selected.length).toBeGreaterThanOrEqual(2); const selectedIds = selectionEngine.getSelectedIds(); expect(selectedIds.has('el-line-1')).toBe(true); expect(selectedIds.has('el-rect-1')).toBe(true); }); it('should box-select with crossing mode (right-to-left drag)', () => { // Crossing selection: right-to-left drag (start X > end X) // Use a box that intersects the circle but doesn't fully enclose it // circle bbox: minX=360, minY=360, maxX=440, maxY=440 selectionEngine.startBoxSelect(420, 420); selectionEngine.updateBoxSelect(380, 380, elements); const selected = selectionEngine.finishBoxSelect(elements); // Circle should be selected (intersecting) expect(selected.length).toBeGreaterThanOrEqual(1); expect(selectionEngine.getSelectedIds().has('el-circle-1')).toBe(true); }); it('should support additive selection (shift-click)', () => { // Select line first selectionEngine.clickSelect(50, 50, elements); expect(selectionEngine.getSelectedIds().size).toBe(1); // Additive select rect selectionEngine.setOptions({ additive: true }); selectionEngine.clickSelect(200, 200, elements); const selectedIds = selectionEngine.getSelectedIds(); expect(selectedIds.size).toBe(2); expect(selectedIds.has('el-line-1')).toBe(true); expect(selectedIds.has('el-rect-1')).toBe(true); }); }); // ─── Step 7: Group selected elements ───────────────────────────────────────── describe('Step 7: Grouping', () => { beforeEach(() => { layerManager.addLayer(makeLayer('layer-default', { sortOrder: 0 })); layerManager.addLayer(makeLayer('layer-arch', { sortOrder: 1 })); elements = [ makeLine('el-line-1', 0, 0, 100, 100, 'layer-default'), makeRect('el-rect-1', 200, 200, 80, 60, 'layer-arch'), makeCircle('el-circle-1', 400, 400, 40, 'layer-default'), ]; for (const el of elements) spatialIndex.insert(el); }); it('should group selected elements and verify membership', () => { // Select two elements selectionEngine.selectByIds(['el-line-1', 'el-rect-1']); const selectedIds = selectionEngine.getSelectedIds(); expect(selectedIds.size).toBe(2); // Create group from selected elements const group = groupManager.createGroup(['el-line-1', 'el-rect-1'], 'Test Group'); expect(group.id).toBeDefined(); expect(group.name).toBe('Test Group'); expect(group.elementIds).toHaveLength(2); expect(group.elementIds).toContain('el-line-1'); expect(group.elementIds).toContain('el-rect-1'); // Verify group membership expect(groupManager.getGroupForElement('el-line-1')).toBe(group.id); expect(groupManager.getGroupForElement('el-rect-1')).toBe(group.id); expect(groupManager.getGroupForElement('el-circle-1')).toBeNull(); // Verify grouped elements set const grouped = groupManager.getGroupedElements(); expect(grouped.size).toBe(2); expect(grouped.has('el-line-1')).toBe(true); expect(grouped.has('el-rect-1')).toBe(true); // Verify group retrieval const retrieved = groupManager.getGroup(group.id); expect(retrieved).toBeDefined(); expect(retrieved?.name).toBe('Test Group'); // Ungroup const ungroupedIds = groupManager.ungroup(group.id); expect(ungroupedIds).toHaveLength(2); expect(groupManager.getGroups()).toHaveLength(0); expect(groupManager.getGroupedElements().size).toBe(0); }); }); // ─── Step 8: Snap to endpoints/midpoints ───────────────────────────────────── describe('Step 8: Snap engine', () => { beforeEach(() => { elements = [ makeLine('el-line-1', 0, 0, 100, 100, 'layer-default'), makeCircle('el-circle-1', 200, 200, 40, 'layer-default'), ]; snapEngine.setElements(elements); }); it('should snap to line endpoint', () => { // Line endpoints: (0,0) and (100,100) // Click near (0, 0) within tolerance const result = snapEngine.snap(2, 2); expect(result.point).not.toBeNull(); expect(result.point?.type).toBe('endpoint'); expect(result.point?.x).toBeCloseTo(0, 1); expect(result.point?.y).toBeCloseTo(0, 1); }); it('should snap to second line endpoint', () => { const result = snapEngine.snap(98, 98); expect(result.point).not.toBeNull(); expect(result.point?.type).toBe('endpoint'); expect(result.point?.x).toBeCloseTo(100, 1); expect(result.point?.y).toBeCloseTo(100, 1); }); it('should snap to line midpoint', () => { // Line midpoint: (50, 50) const result = snapEngine.snap(52, 52); expect(result.point).not.toBeNull(); // Endpoint (0,0) is ~74 units away, midpoint (50,50) is ~2.8 units away // But endpoint priority > midpoint priority, so if both within tolerance * 1.5... // tolerance=10, so endpoint at distance ~74 is NOT within 10*1.5=15 // midpoint at distance ~2.8 IS within 15 expect(result.point?.type).toBe('midpoint'); expect(result.point?.x).toBeCloseTo(50, 1); expect(result.point?.y).toBeCloseTo(50, 1); }); it('should snap to circle center', () => { // Circle center: (200, 200) const result = snapEngine.snap(202, 202); expect(result.point).not.toBeNull(); expect(result.point?.type).toBe('center'); expect(result.point?.x).toBeCloseTo(200, 1); expect(result.point?.y).toBeCloseTo(200, 1); }); it('should return null when no snap point is near', () => { // Click far from any element const result = snapEngine.snap(500, 500); expect(result.point).toBeNull(); }); it('should return preview candidates', () => { const result = snapEngine.snap(2, 2); expect(result.preview.length).toBeGreaterThan(0); }); }); // ─── Step 9: History (push, undo, redo) ────────────────────────────────────── describe('Step 9: History management', () => { beforeEach(() => { layerManager.addLayer(makeLayer('layer-default', { sortOrder: 0 })); }); it('should push snapshot, modify, undo, redo and verify state restored', () => { const initialElements = [ makeLine('el-line-1', 0, 0, 100, 100, 'layer-default'), ]; const initialLayers = layerManager.getLayers(); // Initialize history with initial state historyManager.initialize(makeSnapshot(initialElements, initialLayers)); expect(historyManager.getCurrentState()?.elements).toHaveLength(1); expect(historyManager.canUndo()).toBe(false); expect(historyManager.canRedo()).toBe(false); // Push a new state: add a rect element const modifiedElements = [ ...initialElements, makeRect('el-rect-1', 50, 50, 80, 60, 'layer-default'), ]; historyManager.pushSnapshot(makeSnapshot(modifiedElements, initialLayers), 'Add rect'); expect(historyManager.getCurrentState()?.elements).toHaveLength(2); expect(historyManager.canUndo()).toBe(true); expect(historyManager.canRedo()).toBe(false); // Undo: should restore to 1 element const undone = historyManager.undo(); expect(undone).not.toBeNull(); expect(undone?.elements).toHaveLength(1); expect(undone?.elements[0].id).toBe('el-line-1'); expect(historyManager.canRedo()).toBe(true); // Redo: should restore to 2 elements const redone = historyManager.redo(); expect(redone).not.toBeNull(); expect(redone?.elements).toHaveLength(2); expect(redone?.elements[1].id).toBe('el-rect-1'); expect(historyManager.canUndo()).toBe(true); expect(historyManager.canRedo()).toBe(false); }); it('should handle multiple undo/redo cycles', () => { const layers = layerManager.getLayers(); // Initialize historyManager.initialize(makeSnapshot([], layers)); // Push state 1 const els1 = [makeLine('el-1', 0, 0, 50, 50, 'layer-default')]; historyManager.pushSnapshot(makeSnapshot(els1, layers), 'Add line 1'); // Push state 2 const els2 = [...els1, makeLine('el-2', 100, 100, 200, 200, 'layer-default')]; historyManager.pushSnapshot(makeSnapshot(els2, layers), 'Add line 2'); // Push state 3 const els3 = [...els2, makeLine('el-3', 300, 300, 400, 400, 'layer-default')]; historyManager.pushSnapshot(makeSnapshot(els3, layers), 'Add line 3'); expect(historyManager.getCurrentState()?.elements).toHaveLength(3); expect(historyManager.getUndoCount()).toBe(3); // Undo twice historyManager.undo(); expect(historyManager.getCurrentState()?.elements).toHaveLength(2); historyManager.undo(); expect(historyManager.getCurrentState()?.elements).toHaveLength(1); expect(historyManager.getRedoCount()).toBe(2); // Redo once historyManager.redo(); expect(historyManager.getCurrentState()?.elements).toHaveLength(2); expect(historyManager.getRedoCount()).toBe(1); }); }); // ─── Step 10: Layer toggle (hide layer, verify not rendered/selectable) ────── describe('Step 10: Layer toggle affects rendering and selection', () => { beforeEach(() => { layerManager.addLayer(makeLayer('layer-default', { sortOrder: 0 })); layerManager.addLayer(makeLayer('layer-hidden', { sortOrder: 1 })); elements = [ makeLine('el-line-1', 0, 0, 100, 100, 'layer-default'), makeRect('el-rect-1', 200, 200, 80, 60, 'layer-hidden'), ]; for (const el of elements) spatialIndex.insert(el); }); it('should not render elements on hidden layers', () => { // Initially both layers visible — render should work expect(() => renderEngine.render()).not.toThrow(); // Hide layer-hidden layerManager.toggleVisibility('layer-hidden'); expect(layerManager.getLayer('layer-hidden')?.visible).toBe(false); // Render should still not crash (just skips hidden layer elements) expect(() => renderEngine.render()).not.toThrow(); // getVisibleLayers should not include hidden layer const visible = layerManager.getVisibleLayers(); expect(visible.some(l => l.id === 'layer-hidden')).toBe(false); }); it('should not select elements on hidden layers via hitTest', () => { // Hide layer-hidden layerManager.toggleVisibility('layer-hidden'); // Try to click-select the rect on hidden layer const hit = selectionEngine.clickSelect(200, 200, elements); expect(hit).toBeNull(); expect(selectionEngine.getSelectedIds().size).toBe(0); }); it('should not select elements on locked layers via hitTest', () => { // Lock layer-hidden (visible but locked → not in getVisibleLayers) layerManager.toggleLock('layer-hidden'); // Try to click-select the rect on locked layer const hit = selectionEngine.clickSelect(200, 200, elements); expect(hit).toBeNull(); }); it('should still select elements on visible, unlocked layers', () => { // Click on line (layer-default, visible, unlocked) const hit = selectionEngine.clickSelect(50, 50, elements); expect(hit).not.toBeNull(); expect(hit?.id).toBe('el-line-1'); }); }); // ─── Step 11: Zoom fit to all elements ─────────────────────────────────────── describe('Step 11: Zoom fit to all elements', () => { it('should change scale when fitting to elements', () => { const initialScale = zoomPan.getScale(); expect(initialScale).toBe(1); // Elements spread across a large area const fitElements = [ { x: 0, y: 0, width: 10, height: 10 }, { x: 500, y: 500, width: 10, height: 10 }, { x: 1000, y: 1000, width: 10, height: 10 }, ]; zoomPan.zoomFit(fitElements); const newScale = zoomPan.getScale(); // Canvas is 800x600, elements span ~1010 units, padding=40 // scaleX = (800-80)/1010 ≈ 0.713, scaleY = (600-80)/1010 ≈ 0.515 // scale = min(0.713, 0.515) ≈ 0.515 expect(newScale).not.toBe(1); expect(newScale).toBeGreaterThan(0); expect(newScale).toBeLessThan(1); }); it('should not change scale when no elements', () => { const initialScale = zoomPan.getScale(); zoomPan.zoomFit([]); expect(zoomPan.getScale()).toBe(initialScale); }); it('should fit and then reset', () => { zoomPan.zoomFit([{ x: 100, y: 100, width: 50, height: 50 }]); expect(zoomPan.getScale()).not.toBe(1); zoomPan.reset(); expect(zoomPan.getScale()).toBe(1); const transform = zoomPan.getTransform(); expect(transform.e).toBe(0); expect(transform.f).toBe(0); }); }); // ─── Step 12: Reset everything, verify clean state ─────────────────────────── describe('Step 12: Reset everything', () => { beforeEach(() => { layerManager.addLayer(makeLayer('layer-default', { sortOrder: 0 })); layerManager.addLayer(makeLayer('layer-arch', { sortOrder: 1 })); elements = [ makeLine('el-line-1', 0, 0, 100, 100, 'layer-default'), makeRect('el-rect-1', 200, 200, 80, 60, 'layer-arch'), ]; for (const el of elements) spatialIndex.insert(el); // Modify state zoomPan.zoomAt(400, 300, 2); zoomPan.pan(50, 30); selectionEngine.selectByIds(['el-line-1', 'el-rect-1']); groupManager.createGroup(['el-line-1', 'el-rect-1'], 'Test'); historyManager.initialize(makeSnapshot(elements, layerManager.getLayers())); historyManager.pushSnapshot(makeSnapshot([...elements, makeCircle('el-c', 300, 300, 20)], layerManager.getLayers()), 'Add circle'); }); it('should reset all components to clean state', () => { // Verify dirty state before reset expect(zoomPan.getScale()).not.toBe(1); expect(selectionEngine.getSelectedIds().size).toBe(2); expect(groupManager.getGroups().length).toBe(1); expect(spatialIndex.search({ minX: -1000, minY: -1000, maxX: 1000, maxY: 1000 }).length).toBe(2); expect(layerManager.getLayers().length).toBe(2); expect(historyManager.canUndo()).toBe(true); // Reset all zoomPan.reset(); selectionEngine.clearSelection(); groupManager.clear(); spatialIndex.clear(); layerManager.clear(); historyManager.clear(); // Verify clean state expect(zoomPan.getScale()).toBe(1); expect(zoomPan.getTransform().e).toBe(0); expect(zoomPan.getTransform().f).toBe(0); expect(selectionEngine.getSelectedIds().size).toBe(0); expect(groupManager.getGroups()).toHaveLength(0); expect(groupManager.getGroupedElements().size).toBe(0); expect(spatialIndex.search({ minX: -1000, minY: -1000, maxX: 1000, maxY: 1000 })).toHaveLength(0); expect(layerManager.getLayers()).toHaveLength(0); expect(layerManager.getActiveLayerId()).toBe(''); expect(historyManager.getCurrentState()).toBeNull(); expect(historyManager.canUndo()).toBe(false); expect(historyManager.canRedo()).toBe(false); }); }); // ─── Full Workflow Integration ────────────────────────────────────────────── describe('Full workflow: init → layers → elements → render → zoom → select → group → snap → history → toggle → fit → reset', () => { it('should execute the complete CAD workflow end-to-end', () => { // ── 1. Initialize ── expect(renderEngine).toBeDefined(); expect(selectionEngine).toBeDefined(); // ── 2. Create layers ── const defaultLayer = makeLayer('layer-default', { sortOrder: 0, name: 'Default' }); const archLayer = makeLayer('layer-arch', { sortOrder: 1, name: 'Architecture', color: '#ff0000' }); layerManager.addLayer(defaultLayer); layerManager.addLayer(archLayer); expect(layerManager.getLayers()).toHaveLength(2); // ── 3. Add elements to different layers ── const line1 = makeLine('el-line-1', 0, 0, 100, 100, 'layer-default'); const rect1 = makeRect('el-rect-1', 200, 200, 80, 60, 'layer-arch'); const circle1 = makeCircle('el-circle-1', 400, 300, 40, 'layer-default'); elements = [line1, rect1, circle1]; for (const el of elements) spatialIndex.insert(el); // Verify spatial index has all elements const allFound = spatialIndex.search({ minX: -200, minY: -200, maxX: 600, maxY: 600 }); expect(allFound).toHaveLength(3); // ── 4. Render ── expect(() => renderEngine.render()).not.toThrow(); // ── 5. Zoom/Pan ── zoomPan.zoomAt(400, 300, 1.5); expect(zoomPan.getScale()).toBe(1.5); // zoomAt(400,300,1.5): offsetX = 400-(400-0)*1.5 = -200, then pan(20,10) → -180 zoomPan.pan(20, 10); expect(zoomPan.getTransform().e).toBe(-180); // Roundtrip const wpt = { x: 100, y: 100 }; const spt = zoomPan.worldToScreen(wpt.x, wpt.y); const back = zoomPan.screenToWorld(spt.x, spt.y); expect(back.x).toBeCloseTo(wpt.x, 3); expect(back.y).toBeCloseTo(wpt.y, 3); // ── 6. Selection ── // Click select line at midpoint (50, 50) const hit = selectionEngine.clickSelect(50, 50, elements); expect(hit?.id).toBe('el-line-1'); // Box select line + rect (window selection) selectionEngine.clearSelection(); selectionEngine.startBoxSelect(-100, -100); selectionEngine.updateBoxSelect(300, 300, elements); const boxSelected = selectionEngine.finishBoxSelect(elements); expect(boxSelected.length).toBeGreaterThanOrEqual(2); // ── 7. Group selected elements ── const selectedIds = Array.from(selectionEngine.getSelectedIds()); const group = groupManager.createGroup(selectedIds, 'Workflow Group'); expect(group.elementIds.length).toBeGreaterThanOrEqual(2); expect(groupManager.getGroup(group.id)).toBeDefined(); // ── 8. Snap ── snapEngine.setElements(elements); const snapResult = snapEngine.snap(2, 2); // Near line endpoint (0,0) expect(snapResult.point).not.toBeNull(); expect(snapResult.point?.type).toBe('endpoint'); // ── 9. History ── const currentLayers = layerManager.getLayers(); historyManager.initialize(makeSnapshot(elements, currentLayers)); const modifiedEls = [...elements, makeRect('el-rect-2', 500, 500, 30, 30, 'layer-default')]; historyManager.pushSnapshot(makeSnapshot(modifiedEls, currentLayers), 'Add rect 2'); expect(historyManager.getCurrentState()?.elements).toHaveLength(4); expect(historyManager.canUndo()).toBe(true); const undone = historyManager.undo(); expect(undone?.elements).toHaveLength(3); const redone = historyManager.redo(); expect(redone?.elements).toHaveLength(4); // ── 10. Layer toggle ── layerManager.toggleVisibility('layer-arch'); expect(layerManager.getLayer('layer-arch')?.visible).toBe(false); // Elements on hidden layer should not be selectable const hitAfterHide = selectionEngine.clickSelect(200, 200, elements); expect(hitAfterHide).toBeNull(); // Restore visibility layerManager.toggleVisibility('layer-arch'); expect(layerManager.getLayer('layer-arch')?.visible).toBe(true); // ── 11. Zoom fit ── zoomPan.reset(); expect(zoomPan.getScale()).toBe(1); zoomPan.zoomFit(elements); expect(zoomPan.getScale()).not.toBe(1); // ── 12. Reset everything ── zoomPan.reset(); selectionEngine.clearSelection(); groupManager.clear(); spatialIndex.clear(); layerManager.clear(); historyManager.clear(); expect(zoomPan.getScale()).toBe(1); expect(selectionEngine.getSelectedIds().size).toBe(0); expect(groupManager.getGroups()).toHaveLength(0); expect(spatialIndex.search({ minX: -1000, minY: -1000, maxX: 1000, maxY: 1000 })).toHaveLength(0); expect(layerManager.getLayers()).toHaveLength(0); expect(historyManager.getCurrentState()).toBeNull(); }); }); // ─── CommandRegistry Integration ───────────────────────────────────────────── describe('CommandRegistry integration', () => { it('should look up drawing commands', () => { const lineCmd = commandRegistry.lookup('LINE'); expect(lineCmd).not.toBeNull(); expect(lineCmd?.toolId).toBe('line'); const circleCmd = commandRegistry.lookup('C'); expect(circleCmd?.name).toBe('CIRCLE'); expect(circleCmd?.toolId).toBe('circle'); }); it('should provide autocomplete suggestions', () => { const suggestions = commandRegistry.autocomplete('LI'); expect(suggestions.length).toBeGreaterThan(0); expect(suggestions.some(c => c.name === 'LINE')).toBe(true); }); it('should return null for unknown commands', () => { expect(commandRegistry.lookup('UNKNOWN')).toBeNull(); }); }); });