test: add Vitest setup with 115 tests (backend + frontend)
This commit is contained in:
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user