test: add Vitest setup with 115 tests (backend + frontend)

This commit is contained in:
2026-06-26 14:19:50 +02:00
parent 19884b7b5d
commit f853372c90
11 changed files with 2062 additions and 42 deletions
+388
View File
@@ -0,0 +1,388 @@
/**
* HistoryManager Tests Undo/Redo
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { HistoryManager } from '../src/history/HistoryManager';
import type { CADStateSnapshot } from '../src/history/HistoryManager';
import type { CADElement, CADLayer, BlockDefinition } from '../src/types/cad.types';
import type { ElementGroup } from '../src/tools/modification/GroupTool';
import type { BackgroundConfig } from '../src/services/backgroundService';
function makeSnapshot(label: string, suffix: string = ''): Omit<CADStateSnapshot, 'timestamp' | 'label'> {
const elements: CADElement[] = [
{ id: `el-${suffix}-1`, type: 'rect', layerId: 'layer-1', x: 0, y: 0, width: 10, height: 10, properties: {} },
{ id: `el-${suffix}-2`, type: 'circle', layerId: 'layer-1', x: 50, y: 50, width: 20, height: 20, properties: {} },
];
const layers: CADLayer[] = [
{ id: 'layer-1', name: 'Layer 1', visible: true, locked: false, color: '#ffffff', lineType: 'solid', transparency: 0, sortOrder: 0, parentId: null },
];
const blocks: BlockDefinition[] = [];
const groups: ElementGroup[] = [];
const bgConfig: BackgroundConfig | null = null;
return { elements, layers, blocks, groups, bgConfig };
}
describe('HistoryManager', () => {
let hm: HistoryManager;
beforeEach(() => {
hm = new HistoryManager();
});
describe('initialize', () => {
it('should set initial state without undo history', () => {
const snap = makeSnapshot('Initial');
hm.initialize(snap);
expect(hm.getCurrentState()).not.toBeNull();
expect(hm.getCurrentState()?.label).toBe('Initial');
expect(hm.canUndo()).toBe(false);
expect(hm.canRedo()).toBe(false);
});
it('should reset undo and redo stacks on initialize', () => {
const snap = makeSnapshot('Initial');
hm.initialize(snap);
hm.pushSnapshot(makeSnapshot('Change 1'), 'Change 1');
hm.pushSnapshot(makeSnapshot('Change 2'), 'Change 2');
// Re-initialize should clear history
hm.initialize(snap);
expect(hm.canUndo()).toBe(false);
expect(hm.canRedo()).toBe(false);
expect(hm.getUndoCount()).toBe(0);
expect(hm.getRedoCount()).toBe(0);
});
});
describe('pushSnapshot', () => {
it('should push current state to undo stack and set new state', () => {
hm.initialize(makeSnapshot('Initial'));
hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1');
expect(hm.getCurrentState()?.label).toBe('Change 1');
expect(hm.canUndo()).toBe(true);
expect(hm.getUndoCount()).toBe(1);
});
it('should clear redo stack on new push', () => {
hm.initialize(makeSnapshot('Initial'));
hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1');
hm.undo(); // Now redo stack has 1 entry
expect(hm.canRedo()).toBe(true);
hm.pushSnapshot(makeSnapshot('Change 2', 'v2'), 'Change 2');
expect(hm.canRedo()).toBe(false);
expect(hm.getRedoCount()).toBe(0);
});
it('should store multiple snapshots', () => {
hm.initialize(makeSnapshot('Initial'));
for (let i = 1; i <= 5; i++) {
hm.pushSnapshot(makeSnapshot(`Change ${i}`, `v${i}`), `Change ${i}`);
}
expect(hm.getUndoCount()).toBe(5);
expect(hm.getCurrentState()?.label).toBe('Change 5');
});
});
describe('undo', () => {
it('should restore previous state', () => {
hm.initialize(makeSnapshot('Initial'));
hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1');
const prev = hm.undo();
expect(prev).not.toBeNull();
expect(prev?.label).toBe('Initial');
expect(hm.getCurrentState()?.label).toBe('Initial');
});
it('should return null when no undo available', () => {
hm.initialize(makeSnapshot('Initial'));
const result = hm.undo();
expect(result).toBeNull();
});
it('should move current state to redo stack', () => {
hm.initialize(makeSnapshot('Initial'));
hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1');
hm.undo();
expect(hm.canRedo()).toBe(true);
expect(hm.getRedoCount()).toBe(1);
});
it('should handle multiple undos', () => {
hm.initialize(makeSnapshot('Initial'));
hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1');
hm.pushSnapshot(makeSnapshot('Change 2', 'v2'), 'Change 2');
hm.pushSnapshot(makeSnapshot('Change 3', 'v3'), 'Change 3');
hm.undo();
expect(hm.getCurrentState()?.label).toBe('Change 2');
hm.undo();
expect(hm.getCurrentState()?.label).toBe('Change 1');
hm.undo();
expect(hm.getCurrentState()?.label).toBe('Initial');
expect(hm.canUndo()).toBe(false);
});
});
describe('redo', () => {
it('should restore next state after undo', () => {
hm.initialize(makeSnapshot('Initial'));
hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1');
hm.undo();
const next = hm.redo();
expect(next).not.toBeNull();
expect(next?.label).toBe('Change 1');
expect(hm.getCurrentState()?.label).toBe('Change 1');
});
it('should return null when no redo available', () => {
hm.initialize(makeSnapshot('Initial'));
const result = hm.redo();
expect(result).toBeNull();
});
it('should move current state back to undo stack', () => {
hm.initialize(makeSnapshot('Initial'));
hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1');
hm.undo();
hm.redo();
expect(hm.canUndo()).toBe(true);
expect(hm.getUndoCount()).toBe(1);
});
it('should handle multiple redos', () => {
hm.initialize(makeSnapshot('Initial'));
hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1');
hm.pushSnapshot(makeSnapshot('Change 2', 'v2'), 'Change 2');
hm.pushSnapshot(makeSnapshot('Change 3', 'v3'), 'Change 3');
// Undo all 3
hm.undo(); hm.undo(); hm.undo();
expect(hm.getCurrentState()?.label).toBe('Initial');
// Redo all 3
hm.redo();
expect(hm.getCurrentState()?.label).toBe('Change 1');
hm.redo();
expect(hm.getCurrentState()?.label).toBe('Change 2');
hm.redo();
expect(hm.getCurrentState()?.label).toBe('Change 3');
expect(hm.canRedo()).toBe(false);
});
});
describe('canUndo & canRedo', () => {
it('canUndo should be false initially', () => {
hm.initialize(makeSnapshot('Initial'));
expect(hm.canUndo()).toBe(false);
});
it('canUndo should be true after push', () => {
hm.initialize(makeSnapshot('Initial'));
hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1');
expect(hm.canUndo()).toBe(true);
});
it('canRedo should be false initially', () => {
hm.initialize(makeSnapshot('Initial'));
expect(hm.canRedo()).toBe(false);
});
it('canRedo should be true after undo', () => {
hm.initialize(makeSnapshot('Initial'));
hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1');
hm.undo();
expect(hm.canRedo()).toBe(true);
});
});
describe('getHistory', () => {
it('should return history entries with current marked', () => {
hm.initialize(makeSnapshot('Initial'));
hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1');
hm.pushSnapshot(makeSnapshot('Change 2', 'v2'), 'Change 2');
const history = hm.getHistory();
expect(history.length).toBe(3); // 2 undo + 1 current
const currentEntries = history.filter(e => e.isCurrent);
expect(currentEntries.length).toBe(1);
expect(currentEntries[0].label).toBe('Change 2');
});
it('should include redo entries after undo', () => {
hm.initialize(makeSnapshot('Initial'));
hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1');
hm.undo();
const history = hm.getHistory();
// 0 undo + 1 current (Initial) + 1 redo (Change 1)
expect(history.length).toBe(2);
const currentEntries = history.filter(e => e.isCurrent);
expect(currentEntries.length).toBe(1);
expect(currentEntries[0].label).toBe('Initial');
});
it('should return empty-ish history for fresh manager', () => {
const history = hm.getHistory();
expect(history.length).toBe(0);
});
});
describe('jumpTo', () => {
it('should jump to a specific undo entry', () => {
hm.initialize(makeSnapshot('Initial'));
hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1');
hm.pushSnapshot(makeSnapshot('Change 2', 'v2'), 'Change 2');
hm.pushSnapshot(makeSnapshot('Change 3', 'v3'), 'Change 3');
// Jump to undo-0 (Initial state)
const result = hm.jumpTo('undo-0');
expect(result).not.toBeNull();
expect(hm.getCurrentState()?.label).toBe('Initial');
});
it('should jump to current', () => {
hm.initialize(makeSnapshot('Initial'));
hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1');
const result = hm.jumpTo('current');
expect(result).not.toBeNull();
expect(result?.label).toBe('Change 1');
});
it('should jump to a redo entry', () => {
hm.initialize(makeSnapshot('Initial'));
hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1');
hm.pushSnapshot(makeSnapshot('Change 2', 'v2'), 'Change 2');
hm.undo();
hm.undo();
// Jump to redo-1 (Change 2)
const result = hm.jumpTo('redo-1');
expect(result).not.toBeNull();
expect(hm.getCurrentState()?.label).toBe('Change 2');
});
it('should return null for unknown entry id', () => {
hm.initialize(makeSnapshot('Initial'));
const result = hm.jumpTo('unknown-id');
expect(result).toBeNull();
});
});
describe('clear', () => {
it('should clear all history', () => {
hm.initialize(makeSnapshot('Initial'));
hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1');
hm.pushSnapshot(makeSnapshot('Change 2', 'v2'), 'Change 2');
hm.clear();
expect(hm.getCurrentState()).toBeNull();
expect(hm.canUndo()).toBe(false);
expect(hm.canRedo()).toBe(false);
expect(hm.getUndoCount()).toBe(0);
expect(hm.getRedoCount()).toBe(0);
});
});
describe('subscribe', () => {
it('should notify listeners on state change', () => {
let callCount = 0;
const unsubscribe = hm.subscribe(() => callCount++);
hm.initialize(makeSnapshot('Initial'));
expect(callCount).toBe(1);
hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1');
expect(callCount).toBe(2);
hm.undo();
expect(callCount).toBe(3);
unsubscribe();
hm.pushSnapshot(makeSnapshot('Change 2', 'v2'), 'Change 2');
expect(callCount).toBe(3); // No new calls after unsubscribe
});
it('should notify on clear', () => {
let callCount = 0;
hm.subscribe(() => callCount++);
hm.initialize(makeSnapshot('Initial'));
hm.clear();
expect(callCount).toBe(2); // init + clear
});
});
describe('maxStackSize', () => {
it('should limit undo stack size', () => {
const smallHM = new HistoryManager({ maxStackSize: 3 });
smallHM.initialize(makeSnapshot('Initial'));
for (let i = 1; i <= 10; i++) {
smallHM.pushSnapshot(makeSnapshot(`Change ${i}`, `v${i}`), `Change ${i}`);
}
expect(smallHM.getUndoCount()).toBe(3);
});
it('should use default max size of 100', () => {
const defaultHM = new HistoryManager();
defaultHM.initialize(makeSnapshot('Initial'));
for (let i = 1; i <= 150; i++) {
defaultHM.pushSnapshot(makeSnapshot(`Change ${i}`, `v${i}`), `Change ${i}`);
}
expect(defaultHM.getUndoCount()).toBe(100);
});
});
describe('getUndoCount & getRedoCount', () => {
it('should track counts correctly', () => {
hm.initialize(makeSnapshot('Initial'));
expect(hm.getUndoCount()).toBe(0);
expect(hm.getRedoCount()).toBe(0);
hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1');
expect(hm.getUndoCount()).toBe(1);
expect(hm.getRedoCount()).toBe(0);
hm.undo();
expect(hm.getUndoCount()).toBe(0);
expect(hm.getRedoCount()).toBe(1);
hm.redo();
expect(hm.getUndoCount()).toBe(1);
expect(hm.getRedoCount()).toBe(0);
});
});
describe('snapshot data integrity', () => {
it('should preserve elements in snapshots', () => {
const snap = makeSnapshot('Test');
hm.initialize(snap);
const current = hm.getCurrentState();
expect(current?.elements.length).toBe(2);
expect(current?.elements[0].id).toBe('el--1');
});
it('should preserve different element sets across snapshots', () => {
hm.initialize(makeSnapshot('Initial'));
const snap1 = makeSnapshot('Change 1', 'v1');
hm.pushSnapshot(snap1, 'Change 1');
hm.undo();
const afterUndo = hm.getCurrentState();
expect(afterUndo?.elements[0].id).toBe('el--1'); // Initial state
hm.redo();
const afterRedo = hm.getCurrentState();
expect(afterRedo?.elements[0].id).toBe('el-v1-1'); // Change 1 state
});
});
});
+346
View File
@@ -0,0 +1,346 @@
/**
* LayerManager Tests Layer-Verwaltung
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { LayerManager } from '../src/canvas/LayerManager';
import type { CADLayer } from '../src/types/cad.types';
function makeLayer(id: string, overrides: Partial<CADLayer> = {}): CADLayer {
return {
id,
name: id,
visible: true,
locked: false,
color: '#ffffff',
lineType: 'solid',
transparency: 0,
sortOrder: 0,
parentId: null,
...overrides,
};
}
describe('LayerManager', () => {
let lm: LayerManager;
beforeEach(() => {
lm = new LayerManager();
});
describe('addLayer & getLayer', () => {
it('should add a layer and retrieve it by id', () => {
const layer = makeLayer('layer-1', { name: 'Walls' });
lm.addLayer(layer);
expect(lm.getLayer('layer-1')).toBeDefined();
expect(lm.getLayer('layer-1')?.name).toBe('Walls');
});
it('should set first added layer as active', () => {
lm.addLayer(makeLayer('layer-1'));
expect(lm.getActiveLayerId()).toBe('layer-1');
});
it('should not set active layer if already set', () => {
lm.addLayer(makeLayer('layer-1'));
lm.addLayer(makeLayer('layer-2'));
expect(lm.getActiveLayerId()).toBe('layer-1');
});
it('should return undefined for non-existent layer', () => {
expect(lm.getLayer('non-existent')).toBeUndefined();
});
});
describe('removeLayer', () => {
it('should remove a layer', () => {
lm.addLayer(makeLayer('layer-1'));
lm.addLayer(makeLayer('layer-2'));
lm.removeLayer('layer-1');
expect(lm.getLayer('layer-1')).toBeUndefined();
expect(lm.getLayers().length).toBe(1);
});
it('should switch active layer when removing the active one', () => {
lm.addLayer(makeLayer('layer-1'));
lm.addLayer(makeLayer('layer-2'));
lm.setActiveLayer('layer-1');
lm.removeLayer('layer-1');
expect(lm.getActiveLayerId()).toBe('layer-2');
});
it('should have empty active layer id when removing last layer', () => {
lm.addLayer(makeLayer('layer-1'));
lm.removeLayer('layer-1');
expect(lm.getActiveLayerId()).toBe('');
});
});
describe('getLayers', () => {
it('should return layers sorted by sortOrder', () => {
lm.addLayer(makeLayer('layer-3', { sortOrder: 3 }));
lm.addLayer(makeLayer('layer-1', { sortOrder: 1 }));
lm.addLayer(makeLayer('layer-2', { sortOrder: 2 }));
const layers = lm.getLayers();
expect(layers[0].id).toBe('layer-1');
expect(layers[1].id).toBe('layer-2');
expect(layers[2].id).toBe('layer-3');
});
it('should return empty array when no layers', () => {
expect(lm.getLayers()).toEqual([]);
});
});
describe('getVisibleLayers', () => {
it('should return only visible and unlocked layers', () => {
lm.addLayer(makeLayer('layer-1', { visible: true, locked: false }));
lm.addLayer(makeLayer('layer-2', { visible: false, locked: false }));
lm.addLayer(makeLayer('layer-3', { visible: true, locked: true }));
lm.addLayer(makeLayer('layer-4', { visible: true, locked: false }));
const visible = lm.getVisibleLayers();
expect(visible.length).toBe(2);
const ids = visible.map(l => l.id);
expect(ids).toContain('layer-1');
expect(ids).toContain('layer-4');
});
});
describe('setActiveLayer & getActiveLayer', () => {
it('should set active layer if it exists', () => {
lm.addLayer(makeLayer('layer-1'));
lm.addLayer(makeLayer('layer-2'));
lm.setActiveLayer('layer-2');
expect(lm.getActiveLayerId()).toBe('layer-2');
expect(lm.getActiveLayer()?.id).toBe('layer-2');
});
it('should not set active layer if it does not exist', () => {
lm.addLayer(makeLayer('layer-1'));
lm.setActiveLayer('non-existent');
expect(lm.getActiveLayerId()).toBe('layer-1');
});
});
describe('toggleVisibility', () => {
it('should toggle layer visibility', () => {
lm.addLayer(makeLayer('layer-1', { visible: true }));
lm.toggleVisibility('layer-1');
expect(lm.getLayer('layer-1')?.visible).toBe(false);
lm.toggleVisibility('layer-1');
expect(lm.getLayer('layer-1')?.visible).toBe(true);
});
});
describe('toggleLock', () => {
it('should toggle layer lock state', () => {
lm.addLayer(makeLayer('layer-1', { locked: false }));
lm.toggleLock('layer-1');
expect(lm.getLayer('layer-1')?.locked).toBe(true);
lm.toggleLock('layer-1');
expect(lm.getLayer('layer-1')?.locked).toBe(false);
});
});
describe('clear', () => {
it('should clear all layers and reset active', () => {
lm.addLayer(makeLayer('layer-1'));
lm.addLayer(makeLayer('layer-2'));
lm.clear();
expect(lm.getLayers().length).toBe(0);
expect(lm.getActiveLayerId()).toBe('');
});
});
describe('renameLayer', () => {
it('should rename a layer', () => {
lm.addLayer(makeLayer('layer-1', { name: 'Old Name' }));
lm.renameLayer('layer-1', 'New Name');
expect(lm.getLayer('layer-1')?.name).toBe('New Name');
});
it('should do nothing for non-existent layer', () => {
lm.renameLayer('non-existent', 'New Name');
expect(lm.getLayer('non-existent')).toBeUndefined();
});
});
describe('updateLayer', () => {
it('should update multiple layer properties', () => {
lm.addLayer(makeLayer('layer-1', { color: '#ffffff', transparency: 0 }));
lm.updateLayer('layer-1', { color: '#ff0000', transparency: 50 });
const layer = lm.getLayer('layer-1');
expect(layer?.color).toBe('#ff0000');
expect(layer?.transparency).toBe(50);
});
});
describe('setParent', () => {
it('should set parent for a layer', () => {
lm.addLayer(makeLayer('layer-1'));
lm.addLayer(makeLayer('layer-2'));
lm.setParent('layer-2', 'layer-1');
expect(lm.getLayer('layer-2')?.parentId).toBe('layer-1');
});
it('should prevent circular references', () => {
lm.addLayer(makeLayer('layer-1'));
lm.addLayer(makeLayer('layer-2'));
lm.setParent('layer-1', 'layer-2');
lm.setParent('layer-2', 'layer-1'); // Would create cycle
expect(lm.getLayer('layer-2')?.parentId).toBe(null);
});
it('should allow setting parent to null', () => {
lm.addLayer(makeLayer('layer-1'));
lm.addLayer(makeLayer('layer-2'));
lm.setParent('layer-2', 'layer-1');
lm.setParent('layer-2', null);
expect(lm.getLayer('layer-2')?.parentId).toBe(null);
});
});
describe('getChildLayers & getLayerTree', () => {
it('should get child layers of a parent', () => {
lm.addLayer(makeLayer('parent', { sortOrder: 0 }));
lm.addLayer(makeLayer('child-1', { parentId: 'parent', sortOrder: 1 }));
lm.addLayer(makeLayer('child-2', { parentId: 'parent', sortOrder: 2 }));
lm.addLayer(makeLayer('other', { sortOrder: 3 }));
const children = lm.getChildLayers('parent');
expect(children.length).toBe(2);
});
it('should get child layers for null parent (root layers)', () => {
lm.addLayer(makeLayer('root-1', { parentId: null }));
lm.addLayer(makeLayer('root-2', { parentId: null }));
lm.addLayer(makeLayer('child', { parentId: 'root-1' }));
const roots = lm.getChildLayers(null);
expect(roots.length).toBe(2);
});
it('should build a tree structure', () => {
lm.addLayer(makeLayer('root', { sortOrder: 0 }));
lm.addLayer(makeLayer('child-a', { parentId: 'root', sortOrder: 1 }));
lm.addLayer(makeLayer('child-b', { parentId: 'root', sortOrder: 2 }));
lm.addLayer(makeLayer('grandchild', { parentId: 'child-a', sortOrder: 3 }));
const tree = lm.getLayerTree();
expect(tree.length).toBe(1);
expect(tree[0].id).toBe('root');
expect(tree[0].children.length).toBe(2);
expect(tree[0].children[0].children.length).toBe(1);
expect(tree[0].children[0].children[0].id).toBe('grandchild');
});
});
describe('moveLayer', () => {
it('should move layer to new sort order', () => {
lm.addLayer(makeLayer('layer-1', { sortOrder: 0 }));
lm.moveLayer('layer-1', 5);
expect(lm.getLayer('layer-1')?.sortOrder).toBe(5);
});
});
describe('duplicateLayer', () => {
it('should create a copy of the layer', () => {
lm.addLayer(makeLayer('layer-1', { name: 'Original', sortOrder: 1 }));
const copy = lm.duplicateLayer('layer-1');
expect(copy).not.toBeNull();
expect(copy?.id).not.toBe('layer-1');
expect(copy?.name).toContain('Kopie');
expect(copy?.sortOrder).toBe(2);
expect(lm.getLayers().length).toBe(2);
});
it('should return null for non-existent layer', () => {
expect(lm.duplicateLayer('non-existent')).toBeNull();
});
});
describe('filterLayers', () => {
beforeEach(() => {
lm.addLayer(makeLayer('layer-1', { name: 'Walls', visible: true, locked: false, color: '#ff0000' }));
lm.addLayer(makeLayer('layer-2', { name: 'Doors', visible: false, locked: true, color: '#00ff00' }));
lm.addLayer(makeLayer('layer-3', { name: 'Windows', visible: true, locked: false, color: '#ff0000' }));
});
it('should filter by visible', () => {
const result = lm.filterLayers({ visible: true });
expect(result.length).toBe(2);
});
it('should filter by locked', () => {
const result = lm.filterLayers({ locked: true });
expect(result.length).toBe(1);
expect(result[0].id).toBe('layer-2');
});
it('should filter by color', () => {
const result = lm.filterLayers({ color: '#ff0000' });
expect(result.length).toBe(2);
});
it('should filter by name contains (case insensitive)', () => {
const result = lm.filterLayers({ nameContains: 'wall' });
expect(result.length).toBe(1);
expect(result[0].id).toBe('layer-1');
});
it('should filter by multiple criteria', () => {
const result = lm.filterLayers({ visible: true, color: '#ff0000' });
expect(result.length).toBe(2);
});
});
describe('getDescendantIds', () => {
it('should get all descendant IDs recursively', () => {
lm.addLayer(makeLayer('root'));
lm.addLayer(makeLayer('child-1', { parentId: 'root' }));
lm.addLayer(makeLayer('child-2', { parentId: 'root' }));
lm.addLayer(makeLayer('grandchild-1', { parentId: 'child-1' }));
lm.addLayer(makeLayer('grandchild-2', { parentId: 'child-1' }));
const descendants = lm.getDescendantIds('root');
expect(descendants.length).toBe(4);
expect(descendants).toContain('child-1');
expect(descendants).toContain('child-2');
expect(descendants).toContain('grandchild-1');
expect(descendants).toContain('grandchild-2');
});
it('should return empty array for layer with no children', () => {
lm.addLayer(makeLayer('lonely'));
expect(lm.getDescendantIds('lonely')).toEqual([]);
});
});
describe('isLocked', () => {
it('should return true for locked layer', () => {
lm.addLayer(makeLayer('layer-1', { locked: true }));
expect(lm.isLocked('layer-1')).toBe(true);
});
it('should return false for unlocked layer', () => {
lm.addLayer(makeLayer('layer-1', { locked: false }));
expect(lm.isLocked('layer-1')).toBe(false);
});
it('should return false for non-existent layer', () => {
expect(lm.isLocked('non-existent')).toBe(false);
});
});
describe('getLayerColor', () => {
it('should return layer color', () => {
lm.addLayer(makeLayer('layer-1', { color: '#abcdef' }));
expect(lm.getLayerColor('layer-1')).toBe('#abcdef');
});
it('should return undefined for non-existent layer', () => {
expect(lm.getLayerColor('non-existent')).toBeUndefined();
});
});
});
+153
View File
@@ -0,0 +1,153 @@
/**
* SpatialIndex Tests rbush-based spatial search
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { SpatialIndex } from '../src/canvas/SpatialIndex';
import type { CADElement } from '../src/types/cad.types';
function makeElement(id: string, x: number, y: number, width: number, height: number): CADElement {
return {
id,
type: 'rect',
layerId: 'layer-1',
x,
y,
width,
height,
properties: {},
};
}
describe('SpatialIndex', () => {
let index: SpatialIndex;
beforeEach(() => {
index = new SpatialIndex();
});
describe('insert & search', () => {
it('should find an inserted element within its bounding box', () => {
const el = makeElement('el-1', 50, 50, 20, 20);
index.insert(el);
const results = index.search({ minX: 40, minY: 40, maxX: 60, maxY: 60 });
expect(results.length).toBe(1);
expect(results[0].id).toBe('el-1');
});
it('should not find an element outside the search viewport', () => {
const el = makeElement('el-1', 100, 100, 20, 20);
index.insert(el);
const results = index.search({ minX: 0, minY: 0, maxX: 50, maxY: 50 });
expect(results.length).toBe(0);
});
it('should find multiple elements within viewport', () => {
index.insert(makeElement('el-1', 10, 10, 10, 10));
index.insert(makeElement('el-2', 20, 20, 10, 10));
index.insert(makeElement('el-3', 100, 100, 10, 10));
const results = index.search({ minX: 0, minY: 0, maxX: 30, maxY: 30 });
expect(results.length).toBe(2);
const ids = results.map(e => e.id).sort();
expect(ids).toEqual(['el-1', 'el-2']);
});
it('should handle elements at origin', () => {
index.insert(makeElement('el-origin', 0, 0, 10, 10));
const results = index.search({ minX: -5, minY: -5, maxX: 5, maxY: 5 });
expect(results.length).toBe(1);
expect(results[0].id).toBe('el-origin');
});
});
describe('bulkInsert', () => {
it('should bulk insert and find all elements', () => {
const elements = [
makeElement('bulk-1', 10, 10, 5, 5),
makeElement('bulk-2', 20, 20, 5, 5),
makeElement('bulk-3', 30, 30, 5, 5),
makeElement('bulk-4', 200, 200, 5, 5),
];
index.bulkInsert(elements);
const results = index.search({ minX: 0, minY: 0, maxX: 50, maxY: 50 });
expect(results.length).toBe(3);
});
it('should work with empty array', () => {
index.bulkInsert([]);
const results = index.search({ minX: 0, minY: 0, maxX: 100, maxY: 100 });
expect(results.length).toBe(0);
});
});
describe('remove', () => {
it('should remove an element so it is no longer found', () => {
const el = makeElement('el-rm', 50, 50, 10, 10);
index.insert(el);
let results = index.search({ minX: 40, minY: 40, maxX: 60, maxY: 60 });
expect(results.length).toBe(1);
index.remove(el);
results = index.search({ minX: 40, minY: 40, maxX: 60, maxY: 60 });
expect(results.length).toBe(0);
});
it('should remove only the specified element', () => {
const el1 = makeElement('el-keep', 50, 50, 10, 10);
const el2 = makeElement('el-rm', 50, 50, 10, 10);
index.insert(el1);
index.insert(el2);
index.remove(el2);
const results = index.search({ minX: 40, minY: 40, maxX: 60, maxY: 60 });
expect(results.length).toBe(1);
expect(results[0].id).toBe('el-keep');
});
});
describe('clear', () => {
it('should clear all elements from the index', () => {
index.insert(makeElement('el-1', 10, 10, 5, 5));
index.insert(makeElement('el-2', 20, 20, 5, 5));
index.insert(makeElement('el-3', 30, 30, 5, 5));
index.clear();
const results = index.search({ minX: 0, minY: 0, maxX: 100, maxY: 100 });
expect(results.length).toBe(0);
});
});
describe('edge cases', () => {
it('should handle overlapping bounding boxes correctly', () => {
index.insert(makeElement('el-1', 25, 25, 30, 30)); // bbox: 10,10 to 40,40
index.insert(makeElement('el-2', 30, 30, 30, 30)); // bbox: 15,15 to 45,45
const results = index.search({ minX: 10, minY: 10, maxX: 40, maxY: 40 });
expect(results.length).toBe(2);
});
it('should handle zero-size elements', () => {
index.insert(makeElement('el-zero', 50, 50, 0, 0));
const results = index.search({ minX: 49, minY: 49, maxX: 51, maxY: 51 });
expect(results.length).toBe(1);
});
it('should handle negative coordinates', () => {
index.insert(makeElement('el-neg', -50, -50, 20, 20));
const results = index.search({ minX: -60, minY: -60, maxX: -40, maxY: -40 });
expect(results.length).toBe(1);
expect(results[0].id).toBe('el-neg');
});
it('should search with a large viewport containing all elements', () => {
index.insert(makeElement('el-1', 10, 10, 5, 5));
index.insert(makeElement('el-2', 1000, 1000, 5, 5));
const results = index.search({ minX: -10000, minY: -10000, maxX: 10000, maxY: 10000 });
expect(results.length).toBe(2);
});
});
});