Files
web-cad/frontend/tests/IntegrationWorkflow.test.ts
T

912 lines
36 KiB
TypeScript
Raw Normal View History

/**
* 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> = {}): 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<GroupManager['toJSON']> = [],
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();
});
});
});