1bcfaffdd4
- Fix frontend Network error: API_BASE now defaults to empty string (same-origin) - Fix 3 files: api.ts, AuthContext.tsx, Dashboard.tsx - Add Canvas 2D context mock in tests/setup.ts for jsdom - Fix -0 vs +0 in ZoomPanController.getViewport() - Add IntegrationWorkflow.test.ts (35 tests, full CAD workflow)
912 lines
36 KiB
TypeScript
912 lines
36 KiB
TypeScript
/**
|
|
* 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();
|
|
});
|
|
});
|
|
});
|