Web CAD - complete TypeScript source (React 18 frontend, Node.js backend, CRDT collaboration, KI Copilot)
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* Component Tests – RibbonBar, Topbar, StatusBar, LayerPanel, PropertiesPanel
|
||||
* Testet Rendering, Button-Klicks und Callback-Aufrufe.
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import RibbonBar from '../src/components/RibbonBar';
|
||||
import Topbar from '../src/components/Topbar';
|
||||
import StatusBar from '../src/components/StatusBar';
|
||||
import LayerPanel from '../src/components/LayerPanel';
|
||||
import PropertiesPanel from '../src/components/PropertiesPanel';
|
||||
import type { CADElement, CADLayer } from '../src/types/cad.types';
|
||||
|
||||
// ─── Fixtures ────────────────────────────────────────
|
||||
|
||||
function makeLayer(overrides: Partial<CADLayer> = {}): CADLayer {
|
||||
return {
|
||||
id: 'layer-1',
|
||||
name: 'Layer 1',
|
||||
visible: true,
|
||||
locked: false,
|
||||
color: '#ffffff',
|
||||
lineType: 'solid',
|
||||
transparency: 0,
|
||||
sortOrder: 0,
|
||||
parentId: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeElement(overrides: Partial<CADElement> = {}): CADElement {
|
||||
return {
|
||||
id: 'elem-1',
|
||||
type: 'rect',
|
||||
layerId: 'layer-1',
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 100,
|
||||
height: 50,
|
||||
properties: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── RibbonBar ────────────────────────────────────────
|
||||
|
||||
describe('RibbonBar', () => {
|
||||
it('should render all tab buttons', () => {
|
||||
render(<RibbonBar activeTab="start" onTabChange={() => {}} onAction={() => {}} />);
|
||||
// Tabs are in a tablist - use role=tab
|
||||
const tabs = screen.getAllByRole('tab');
|
||||
expect(tabs.length).toBeGreaterThanOrEqual(3);
|
||||
expect(screen.getByText('Start')).toBeInTheDocument();
|
||||
// 'Einfügen' appears as tab AND action - use getAllByText
|
||||
const einfuegen = screen.getAllByText('Einfügen');
|
||||
expect(einfuegen.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('should call onTabChange when clicking a tab', () => {
|
||||
const onTabChange = vi.fn();
|
||||
render(<RibbonBar activeTab="start" onTabChange={onTabChange} onAction={() => {}} />);
|
||||
// Click the 'Format' tab (unique text)
|
||||
fireEvent.click(screen.getByText('Format'));
|
||||
expect(onTabChange).toHaveBeenCalledWith('format');
|
||||
});
|
||||
|
||||
it('should call onAction when clicking an action button', () => {
|
||||
const onAction = vi.fn();
|
||||
render(<RibbonBar activeTab="start" onTabChange={() => {}} onAction={onAction} />);
|
||||
// Find action buttons by class name
|
||||
const actionBtns = document.querySelectorAll('.ribbon-btn');
|
||||
if (actionBtns.length > 0) {
|
||||
fireEvent.click(actionBtns[0]);
|
||||
expect(onAction).toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Topbar ───────────────────────────────────────────
|
||||
|
||||
describe('Topbar', () => {
|
||||
it('should render project name and saved status', () => {
|
||||
render(
|
||||
<Topbar projectName="Test Project" savedStatus="Gespeichert" onUndo={() => {}} onRedo={() => {}} onThemeToggle={() => {}} theme="dark" />,
|
||||
);
|
||||
expect(screen.getByText('Test Project')).toBeInTheDocument();
|
||||
expect(screen.getByText('Gespeichert')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onUndo when clicking undo button', () => {
|
||||
const onUndo = vi.fn();
|
||||
render(
|
||||
<Topbar projectName="P" savedStatus="" onUndo={onUndo} onRedo={() => {}} onThemeToggle={() => {}} theme="dark" />,
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText(/Rückgängig/i));
|
||||
expect(onUndo).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call onRedo when clicking redo button', () => {
|
||||
const onRedo = vi.fn();
|
||||
render(
|
||||
<Topbar projectName="P" savedStatus="" onUndo={() => {}} onRedo={onRedo} onThemeToggle={() => {}} theme="dark" />,
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText(/Wiederherstellen/i));
|
||||
expect(onRedo).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call onThemeToggle when clicking theme button', () => {
|
||||
const onThemeToggle = vi.fn();
|
||||
render(
|
||||
<Topbar projectName="P" savedStatus="" onUndo={() => {}} onRedo={() => {}} onThemeToggle={onThemeToggle} theme="dark" />,
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText(/Hell.*Dunkel|Theme/i));
|
||||
expect(onThemeToggle).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── StatusBar ────────────────────────────────────────
|
||||
|
||||
describe('StatusBar', () => {
|
||||
const defaultProps = {
|
||||
snapEnabled: true,
|
||||
orthoEnabled: false,
|
||||
polarEnabled: true,
|
||||
gridEnabled: false,
|
||||
cursorX: 123.456,
|
||||
cursorY: 78.9,
|
||||
activeLayer: 'Layer 1',
|
||||
activeTool: 'line',
|
||||
onlineCount: 3,
|
||||
onToggleSnap: vi.fn(),
|
||||
onToggleOrtho: vi.fn(),
|
||||
onTogglePolar: vi.fn(),
|
||||
onToggleGrid: vi.fn(),
|
||||
};
|
||||
|
||||
it('should render cursor coordinates', () => {
|
||||
render(<StatusBar {...defaultProps} />);
|
||||
expect(screen.getByText(/123/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/78/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render active layer and tool', () => {
|
||||
render(<StatusBar {...defaultProps} />);
|
||||
expect(screen.getByText('Layer 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('line')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render online count', () => {
|
||||
render(<StatusBar {...defaultProps} />);
|
||||
// Online count is in a div with title containing 'online'
|
||||
const onlineEl = screen.getByTitle(/online/i);
|
||||
expect(onlineEl).toBeInTheDocument();
|
||||
expect(onlineEl.textContent).toContain('3');
|
||||
});
|
||||
|
||||
it('should call onToggleSnap when clicking snap toggle', () => {
|
||||
const onToggleSnap = vi.fn();
|
||||
render(<StatusBar {...defaultProps} onToggleSnap={onToggleSnap} />);
|
||||
// StatusBar uses div with title attribute, not button with aria-label
|
||||
fireEvent.click(screen.getByTitle(/Snap-Modus/i));
|
||||
expect(onToggleSnap).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call onToggleOrtho when clicking ortho toggle', () => {
|
||||
const onToggleOrtho = vi.fn();
|
||||
render(<StatusBar {...defaultProps} onToggleOrtho={onToggleOrtho} />);
|
||||
fireEvent.click(screen.getByTitle(/Ortho-Modus/i));
|
||||
expect(onToggleOrtho).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── LayerPanel ──────────────────────────────────────
|
||||
|
||||
describe('LayerPanel', () => {
|
||||
const layers = [
|
||||
makeLayer({ id: 'layer-1', name: 'Wände' }),
|
||||
makeLayer({ id: 'layer-2', name: 'Türen', visible: false }),
|
||||
];
|
||||
|
||||
it('should render layer names', () => {
|
||||
render(
|
||||
<LayerPanel
|
||||
layers={layers}
|
||||
onSelectLayer={() => {}}
|
||||
onAddLayer={() => {}}
|
||||
onToggleLayer={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Wände')).toBeInTheDocument();
|
||||
expect(screen.getByText('Türen')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onAddLayer when clicking add button', () => {
|
||||
const onAddLayer = vi.fn();
|
||||
render(
|
||||
<LayerPanel
|
||||
layers={layers}
|
||||
onSelectLayer={() => {}}
|
||||
onAddLayer={onAddLayer}
|
||||
onToggleLayer={() => {}}
|
||||
/>,
|
||||
);
|
||||
// Add button has class 'add-layer-btn'
|
||||
const addBtn = document.querySelector('.add-layer-btn');
|
||||
expect(addBtn).toBeTruthy();
|
||||
fireEvent.click(addBtn!);
|
||||
expect(onAddLayer).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call onSelectLayer when clicking a layer', () => {
|
||||
const onSelectLayer = vi.fn();
|
||||
render(
|
||||
<LayerPanel
|
||||
layers={layers}
|
||||
activeLayerId="layer-1"
|
||||
onSelectLayer={onSelectLayer}
|
||||
onAddLayer={() => {}}
|
||||
onToggleLayer={() => {}}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByText('Wände'));
|
||||
expect(onSelectLayer).toHaveBeenCalledWith('layer-1');
|
||||
});
|
||||
|
||||
it('should call onToggleLayer when toggling visibility', () => {
|
||||
const onToggleLayer = vi.fn();
|
||||
render(
|
||||
<LayerPanel
|
||||
layers={layers}
|
||||
onSelectLayer={() => {}}
|
||||
onAddLayer={() => {}}
|
||||
onToggleLayer={onToggleLayer}
|
||||
/>,
|
||||
);
|
||||
// Visibility toggle has aria-label 'Verstecken' (visible) or 'Anzeigen' (hidden)
|
||||
const toggleBtn = screen.getByLabelText('Verstecken');
|
||||
fireEvent.click(toggleBtn);
|
||||
expect(onToggleLayer).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── PropertiesPanel ─────────────────────────────────
|
||||
|
||||
describe('PropertiesPanel', () => {
|
||||
const layers = [makeLayer({ id: 'layer-1', name: 'Layer 1' })];
|
||||
const element = makeElement({ id: 'elem-1', type: 'rect', x: 10, y: 20, width: 100, height: 50 });
|
||||
|
||||
it('should show default values when no element selected', () => {
|
||||
render(<PropertiesPanel selectedElement={null} layers={layers} onUpdateProperty={() => {}} />);
|
||||
// Default values are in inputs with aria-labels
|
||||
expect(screen.getByLabelText('Position X')).toHaveDisplayValue('0.000 m');
|
||||
expect(screen.getByLabelText('Position Y')).toHaveDisplayValue('0.000 m');
|
||||
});
|
||||
|
||||
it('should display element coordinates when element is selected', () => {
|
||||
render(<PropertiesPanel selectedElement={element} layers={layers} onUpdateProperty={() => {}} />);
|
||||
expect(screen.getByLabelText('Position X')).toHaveDisplayValue('10.000 m');
|
||||
expect(screen.getByLabelText('Position Y')).toHaveDisplayValue('20.000 m');
|
||||
});
|
||||
|
||||
it('should display element dimensions', () => {
|
||||
render(<PropertiesPanel selectedElement={element} layers={layers} onUpdateProperty={() => {}} />);
|
||||
expect(screen.getByLabelText('Breite')).toHaveDisplayValue('100.00 m');
|
||||
expect(screen.getByLabelText('Tiefe')).toHaveDisplayValue('50.00 m');
|
||||
});
|
||||
|
||||
it('should call onUpdateProperty when changing a property input', () => {
|
||||
const onUpdateProperty = vi.fn();
|
||||
render(<PropertiesPanel selectedElement={element} layers={layers} onUpdateProperty={onUpdateProperty} />);
|
||||
fireEvent.change(screen.getByLabelText('Position X'), { target: { value: '999' } });
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('x', '999');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* GroupTool Tests – GroupManager: create, ungroup, nest, query
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { GroupManager } from '../src/tools/modification/GroupTool';
|
||||
import type { ElementGroup } from '../src/tools/modification/GroupTool';
|
||||
|
||||
describe('GroupManager', () => {
|
||||
let gm: GroupManager;
|
||||
|
||||
beforeEach(() => {
|
||||
gm = new GroupManager();
|
||||
});
|
||||
|
||||
describe('createGroup', () => {
|
||||
it('should create a group with element IDs', () => {
|
||||
const group = gm.createGroup(['el-1', 'el-2', 'el-3']);
|
||||
expect(group.id).toBeDefined();
|
||||
expect(group.elementIds).toEqual(['el-1', 'el-2', 'el-3']);
|
||||
expect(group.parentGroupId).toBeNull();
|
||||
});
|
||||
|
||||
it('should create a group with a custom name', () => {
|
||||
const group = gm.createGroup(['el-1'], 'My Group');
|
||||
expect(group.name).toBe('My Group');
|
||||
});
|
||||
|
||||
it('should create a group with default name when no name provided', () => {
|
||||
const group = gm.createGroup(['el-1']);
|
||||
expect(group.name).toContain('Group');
|
||||
});
|
||||
|
||||
it('should create multiple groups with unique IDs', () => {
|
||||
const g1 = gm.createGroup(['el-1']);
|
||||
const g2 = gm.createGroup(['el-2']);
|
||||
expect(g1.id).not.toBe(g2.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ungroup', () => {
|
||||
it('should remove a group and return its element IDs', () => {
|
||||
const group = gm.createGroup(['el-1', 'el-2']);
|
||||
const ids = gm.ungroup(group.id);
|
||||
expect(ids).toEqual(['el-1', 'el-2']);
|
||||
expect(gm.getGroup(group.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return empty array for non-existent group', () => {
|
||||
const ids = gm.ungroup('non-existent');
|
||||
expect(ids).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGroup', () => {
|
||||
it('should retrieve a group by ID', () => {
|
||||
const group = gm.createGroup(['el-1']);
|
||||
const retrieved = gm.getGroup(group.id);
|
||||
expect(retrieved).toBeDefined();
|
||||
expect(retrieved!.id).toBe(group.id);
|
||||
});
|
||||
|
||||
it('should return undefined for non-existent group', () => {
|
||||
expect(gm.getGroup('non-existent')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGroups', () => {
|
||||
it('should return all groups', () => {
|
||||
gm.createGroup(['el-1']);
|
||||
gm.createGroup(['el-2']);
|
||||
expect(gm.getGroups().length).toBe(2);
|
||||
});
|
||||
|
||||
it('should return empty array when no groups', () => {
|
||||
expect(gm.getGroups()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGroupedElements', () => {
|
||||
it('should return set of all element IDs in all groups', () => {
|
||||
gm.createGroup(['el-1', 'el-2']);
|
||||
gm.createGroup(['el-3']);
|
||||
const ids = gm.getGroupedElements();
|
||||
expect(ids.size).toBe(3);
|
||||
expect(ids.has('el-1')).toBe(true);
|
||||
expect(ids.has('el-2')).toBe(true);
|
||||
expect(ids.has('el-3')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return empty set when no groups', () => {
|
||||
expect(gm.getGroupedElements().size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGroupForElement', () => {
|
||||
it('should return group ID for an element in a group', () => {
|
||||
const group = gm.createGroup(['el-1', 'el-2']);
|
||||
expect(gm.getGroupForElement('el-1')).toBe(group.id);
|
||||
expect(gm.getGroupForElement('el-2')).toBe(group.id);
|
||||
});
|
||||
|
||||
it('should return null for element not in any group', () => {
|
||||
expect(gm.getGroupForElement('el-lonely')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('moveGroup', () => {
|
||||
it('should return element IDs that need to be moved', () => {
|
||||
const group = gm.createGroup(['el-1', 'el-2']);
|
||||
const ids = gm.moveGroup(group.id, 10, 20);
|
||||
expect(ids).toEqual(['el-1', 'el-2']);
|
||||
});
|
||||
|
||||
it('should return empty array for non-existent group', () => {
|
||||
const ids = gm.moveGroup('non-existent', 10, 20);
|
||||
expect(ids).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setParent (nested groups)', () => {
|
||||
it('should set parent group for nesting', () => {
|
||||
const parent = gm.createGroup(['el-1']);
|
||||
const child = gm.createGroup(['el-2']);
|
||||
gm.setParent(child.id, parent.id);
|
||||
expect(gm.getGroup(child.id)!.parentGroupId).toBe(parent.id);
|
||||
});
|
||||
|
||||
it('should prevent circular references', () => {
|
||||
const g1 = gm.createGroup(['el-1']);
|
||||
const g2 = gm.createGroup(['el-2']);
|
||||
gm.setParent(g1.id, g2.id);
|
||||
// Trying to set g2's parent to g1 would create a cycle: g1 -> g2 -> g1
|
||||
gm.setParent(g2.id, g1.id);
|
||||
expect(gm.getGroup(g2.id)!.parentGroupId).toBeNull();
|
||||
});
|
||||
|
||||
it('should allow setting parent to null', () => {
|
||||
const parent = gm.createGroup(['el-1']);
|
||||
const child = gm.createGroup(['el-2']);
|
||||
gm.setParent(child.id, parent.id);
|
||||
gm.setParent(child.id, null);
|
||||
expect(gm.getGroup(child.id)!.parentGroupId).toBeNull();
|
||||
});
|
||||
|
||||
it('should do nothing for non-existent group', () => {
|
||||
gm.setParent('non-existent', null);
|
||||
expect(gm.getGroup('non-existent')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear', () => {
|
||||
it('should clear all groups', () => {
|
||||
gm.createGroup(['el-1']);
|
||||
gm.createGroup(['el-2']);
|
||||
gm.clear();
|
||||
expect(gm.getGroups().length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toJSON & fromJSON', () => {
|
||||
it('should serialize groups to JSON', () => {
|
||||
gm.createGroup(['el-1', 'el-2'], 'Group A');
|
||||
gm.createGroup(['el-3'], 'Group B');
|
||||
const json = gm.toJSON();
|
||||
expect(json.length).toBe(2);
|
||||
expect(json[0].name).toBe('Group A');
|
||||
expect(json[1].name).toBe('Group B');
|
||||
});
|
||||
|
||||
it('should restore groups from JSON', () => {
|
||||
const groups: ElementGroup[] = [
|
||||
{ id: 'grp-1', name: 'Restored A', elementIds: ['el-1'], parentGroupId: null },
|
||||
{ id: 'grp-2', name: 'Restored B', elementIds: ['el-2', 'el-3'], parentGroupId: 'grp-1' },
|
||||
];
|
||||
gm.fromJSON(groups);
|
||||
expect(gm.getGroups().length).toBe(2);
|
||||
expect(gm.getGroup('grp-1')!.name).toBe('Restored A');
|
||||
expect(gm.getGroup('grp-2')!.parentGroupId).toBe('grp-1');
|
||||
});
|
||||
|
||||
it('should clear existing groups when restoring from JSON', () => {
|
||||
gm.createGroup(['el-old']);
|
||||
gm.fromJSON([
|
||||
{ id: 'grp-new', name: 'New', elementIds: ['el-new'], parentGroupId: null },
|
||||
]);
|
||||
expect(gm.getGroups().length).toBe(1);
|
||||
expect(gm.getGroup('grp-new')).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,911 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,352 @@
|
||||
/**
|
||||
* RenderEngine Tests – Rendering, grid, viewport, hit testing
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { RenderEngine } from '../src/canvas/RenderEngine';
|
||||
import { ZoomPanController } from '../src/canvas/ZoomPanController';
|
||||
import { SpatialIndex } from '../src/canvas/SpatialIndex';
|
||||
import { LayerManager } from '../src/canvas/LayerManager';
|
||||
import type { CADElement, CADLayer } from '../src/types/cad.types';
|
||||
|
||||
function createMockCanvas(w = 800, h = 600): HTMLCanvasElement {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.fillRect = (() => {}) as any;
|
||||
ctx.strokeRect = (() => {}) as any;
|
||||
ctx.beginPath = (() => {}) as any;
|
||||
ctx.moveTo = (() => {}) as any;
|
||||
ctx.lineTo = (() => {}) as any;
|
||||
ctx.stroke = (() => {}) as any;
|
||||
ctx.fill = (() => {}) as any;
|
||||
ctx.save = (() => {}) as any;
|
||||
ctx.restore = (() => {}) as any;
|
||||
ctx.scale = (() => {}) as any;
|
||||
ctx.arc = (() => {}) as any;
|
||||
ctx.setLineDash = (() => {}) as any;
|
||||
ctx.clearRect = (() => {}) as any;
|
||||
ctx.translate = (() => {}) as any;
|
||||
ctx.rotate = (() => {}) as any;
|
||||
ctx.clip = (() => {}) as any;
|
||||
ctx.fillText = (() => {}) as any;
|
||||
ctx.quadraticCurveTo = (() => {}) as any;
|
||||
ctx.closePath = (() => {}) as any;
|
||||
}
|
||||
return canvas;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
function makeLine(id: string, x1: number, y1: number, x2: number, y2: number): CADElement {
|
||||
return {
|
||||
id,
|
||||
type: 'line',
|
||||
layerId: 'layer-1',
|
||||
x: (x1 + x2) / 2,
|
||||
y: (y1 + y2) / 2,
|
||||
width: Math.abs(x2 - x1),
|
||||
height: Math.abs(y2 - y1),
|
||||
properties: { x1, y1, x2, y2 },
|
||||
};
|
||||
}
|
||||
|
||||
function makeRect(id: string, cx: number, cy: number, w: number, h: number): CADElement {
|
||||
return {
|
||||
id,
|
||||
type: 'rect',
|
||||
layerId: 'layer-1',
|
||||
x: cx,
|
||||
y: cy,
|
||||
width: w,
|
||||
height: h,
|
||||
properties: {},
|
||||
};
|
||||
}
|
||||
|
||||
function makeCircle(id: string, cx: number, cy: number, r: number): CADElement {
|
||||
return {
|
||||
id,
|
||||
type: 'circle',
|
||||
layerId: 'layer-1',
|
||||
x: cx,
|
||||
y: cy,
|
||||
width: r * 2,
|
||||
height: r * 2,
|
||||
properties: { radius: r },
|
||||
};
|
||||
}
|
||||
|
||||
describe('RenderEngine', () => {
|
||||
let canvas: HTMLCanvasElement;
|
||||
let zpc: ZoomPanController;
|
||||
let spatialIndex: SpatialIndex;
|
||||
let layerManager: LayerManager;
|
||||
let engine: RenderEngine;
|
||||
|
||||
beforeEach(() => {
|
||||
canvas = createMockCanvas(800, 600);
|
||||
zpc = new ZoomPanController(canvas);
|
||||
spatialIndex = new SpatialIndex();
|
||||
layerManager = new LayerManager();
|
||||
layerManager.addLayer(makeLayer('layer-1'));
|
||||
engine = new RenderEngine(canvas, zpc, spatialIndex, layerManager);
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should construct without error', () => {
|
||||
expect(engine).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have default options with showGrid=true', () => {
|
||||
const opts = engine.getOptions();
|
||||
expect(opts.showGrid).toBe(true);
|
||||
expect(opts.gridSize).toBe(20);
|
||||
});
|
||||
|
||||
it('should have empty selection state', () => {
|
||||
const sel = engine.getSelection();
|
||||
expect(sel.selectedIds.size).toBe(0);
|
||||
expect(sel.hoverId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setOptions & getOptions', () => {
|
||||
it('should update options partially', () => {
|
||||
engine.setOptions({ showGrid: false });
|
||||
expect(engine.getOptions().showGrid).toBe(false);
|
||||
});
|
||||
|
||||
it('should preserve other options when updating one', () => {
|
||||
engine.setOptions({ gridSize: 50 });
|
||||
const opts = engine.getOptions();
|
||||
expect(opts.gridSize).toBe(50);
|
||||
expect(opts.showGrid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setSelection & getSelection', () => {
|
||||
it('should update selection state', () => {
|
||||
engine.setSelection({ hoverId: 'el-1' });
|
||||
expect(engine.getSelection().hoverId).toBe('el-1');
|
||||
});
|
||||
|
||||
it('should set selected ids', () => {
|
||||
engine.setSelection({ selectedIds: new Set(['a', 'b']) });
|
||||
expect(engine.getSelection().selectedIds.size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('render with empty elements', () => {
|
||||
it('should not throw when rendering with no elements', () => {
|
||||
expect(() => engine.render()).not.toThrow();
|
||||
});
|
||||
|
||||
it('should call ctx.fillRect for background', () => {
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
const spy = vi.spyOn(ctx, 'fillRect');
|
||||
engine.render();
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call ctx.save and restore', () => {
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
const saveSpy = vi.spyOn(ctx, 'save');
|
||||
const restoreSpy = vi.spyOn(ctx, 'restore');
|
||||
engine.render();
|
||||
expect(saveSpy).toHaveBeenCalled();
|
||||
expect(restoreSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('render with elements', () => {
|
||||
it('should render a line element (calls stroke)', () => {
|
||||
const line = makeLine('l1', 50, 50, 150, 50);
|
||||
spatialIndex.insert(line);
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
const strokeSpy = vi.spyOn(ctx, 'stroke');
|
||||
engine.render();
|
||||
expect(strokeSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should render a rect element', () => {
|
||||
const rect = makeRect('r1', 100, 100, 80, 60);
|
||||
spatialIndex.insert(rect);
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
const strokeRectSpy = vi.spyOn(ctx, 'strokeRect');
|
||||
engine.render();
|
||||
expect(strokeRectSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should render a circle element', () => {
|
||||
const circle = makeCircle('c1', 100, 100, 40);
|
||||
spatialIndex.insert(circle);
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
const arcSpy = vi.spyOn(ctx, 'arc');
|
||||
engine.render();
|
||||
expect(arcSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not render elements on invisible layers', () => {
|
||||
const line = makeLine('l1', 50, 50, 150, 50);
|
||||
spatialIndex.insert(line);
|
||||
layerManager.addLayer(makeLayer('layer-1', { visible: false }));
|
||||
// Replace the visible layer with invisible
|
||||
layerManager.toggleVisibility('layer-1');
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
const strokeSpy = vi.spyOn(ctx, 'stroke');
|
||||
engine.render();
|
||||
// stroke may still be called for grid, so check that line-specific strokes are minimal
|
||||
// The key assertion is that it doesn't crash
|
||||
expect(strokeSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('grid rendering toggle', () => {
|
||||
it('should call stroke when grid is enabled', () => {
|
||||
engine.setOptions({ showGrid: true });
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
const strokeSpy = vi.spyOn(ctx, 'stroke');
|
||||
engine.render();
|
||||
expect(strokeSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should render without grid when showGrid=false', () => {
|
||||
engine.setOptions({ showGrid: false });
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
const beginPathSpy = vi.spyOn(ctx, 'beginPath');
|
||||
engine.render();
|
||||
// With no grid and no elements, beginPath should not be called for grid
|
||||
// But it might be called for background. We just verify no crash.
|
||||
expect(beginPathSpy).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('snap points', () => {
|
||||
it('should set snap points', () => {
|
||||
engine.setSnapPoints([{ x: 10, y: 10, type: 'endpoint' }]);
|
||||
engine.setOptions({ showSnapPoints: true });
|
||||
expect(() => engine.render()).not.toThrow();
|
||||
});
|
||||
|
||||
it('should set active snap point', () => {
|
||||
engine.setActiveSnapPoint({ x: 10, y: 10, type: 'endpoint' });
|
||||
engine.setOptions({ showSnapPoints: true });
|
||||
expect(() => engine.render()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hitTest', () => {
|
||||
it('should return the element when hit within tolerance', () => {
|
||||
const line = makeLine('l1', 50, 50, 150, 50);
|
||||
spatialIndex.insert(line);
|
||||
const hit = engine.hitTest(100, 50, 5);
|
||||
expect(hit).not.toBeNull();
|
||||
expect(hit!.id).toBe('l1');
|
||||
});
|
||||
|
||||
it('should return null when no element is hit', () => {
|
||||
const line = makeLine('l1', 50, 50, 150, 50);
|
||||
spatialIndex.insert(line);
|
||||
const hit = engine.hitTest(400, 400, 5);
|
||||
expect(hit).toBeNull();
|
||||
});
|
||||
|
||||
it('should hit test a rect element', () => {
|
||||
const rect = makeRect('r1', 100, 100, 80, 60);
|
||||
spatialIndex.insert(rect);
|
||||
const hit = engine.hitTest(100, 100, 5);
|
||||
expect(hit).not.toBeNull();
|
||||
expect(hit!.id).toBe('r1');
|
||||
});
|
||||
|
||||
it('should hit test a circle element', () => {
|
||||
const circle = makeCircle('c1', 100, 100, 40);
|
||||
spatialIndex.insert(circle);
|
||||
const hit = engine.hitTest(140, 100, 5);
|
||||
expect(hit).not.toBeNull();
|
||||
expect(hit!.id).toBe('c1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getElementBBox', () => {
|
||||
it('should return correct bounding box for element', () => {
|
||||
const rect = makeRect('r1', 100, 100, 80, 60);
|
||||
const bb = engine.getElementBBox(rect);
|
||||
expect(bb.minX).toBe(60);
|
||||
expect(bb.minY).toBe(70);
|
||||
expect(bb.maxX).toBe(140);
|
||||
expect(bb.maxY).toBe(130);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getElementsInRect', () => {
|
||||
it('should return fully enclosed elements', () => {
|
||||
const r1 = makeRect('r1', 100, 100, 40, 40);
|
||||
const r2 = makeRect('r2', 300, 300, 40, 40);
|
||||
spatialIndex.insert(r1);
|
||||
spatialIndex.insert(r2);
|
||||
const result = engine.getElementsInRect(50, 50, 200, 200);
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].id).toBe('r1');
|
||||
});
|
||||
|
||||
it('should return empty when no elements in rect', () => {
|
||||
const r1 = makeRect('r1', 100, 100, 40, 40);
|
||||
spatialIndex.insert(r1);
|
||||
const result = engine.getElementsInRect(500, 500, 600, 600);
|
||||
expect(result.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getElementsIntersectingRect', () => {
|
||||
it('should return intersecting elements', () => {
|
||||
const r1 = makeRect('r1', 100, 100, 80, 80);
|
||||
spatialIndex.insert(r1);
|
||||
const result = engine.getElementsIntersectingRect(80, 80, 120, 120);
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].id).toBe('r1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setLayers', () => {
|
||||
it('should clear and set new layers', () => {
|
||||
engine.setLayers([
|
||||
makeLayer('new-1'),
|
||||
makeLayer('new-2'),
|
||||
]);
|
||||
const layers = layerManager.getLayers();
|
||||
expect(layers.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resize', () => {
|
||||
it('should resize canvas dimensions', () => {
|
||||
engine.resize(400, 300);
|
||||
// dpr is likely 1 in jsdom
|
||||
expect(canvas.width).toBeGreaterThanOrEqual(400);
|
||||
expect(canvas.height).toBeGreaterThanOrEqual(300);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setBlockDefinitions', () => {
|
||||
it('should set block definitions without error', () => {
|
||||
engine.setBlockDefinitions([{ id: 'blk-1', elements: [] }]);
|
||||
expect(() => engine.render()).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* SelectionEngine Tests – Selection modes, filters, box selection, hover, listeners
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { SelectionEngine } from '../src/canvas/SelectionEngine';
|
||||
import { RenderEngine } from '../src/canvas/RenderEngine';
|
||||
import { SpatialIndex } from '../src/canvas/SpatialIndex';
|
||||
import { LayerManager } from '../src/canvas/LayerManager';
|
||||
import { ZoomPanController } from '../src/canvas/ZoomPanController';
|
||||
import type { CADElement, CADLayer } from '../src/types/cad.types';
|
||||
|
||||
function createMockCanvas(w = 800, h = 600): HTMLCanvasElement {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.fillRect = (() => {}) as any;
|
||||
ctx.strokeRect = (() => {}) as any;
|
||||
ctx.beginPath = (() => {}) as any;
|
||||
ctx.moveTo = (() => {}) as any;
|
||||
ctx.lineTo = (() => {}) as any;
|
||||
ctx.stroke = (() => {}) as any;
|
||||
ctx.fill = (() => {}) as any;
|
||||
ctx.save = (() => {}) as any;
|
||||
ctx.restore = (() => {}) as any;
|
||||
ctx.scale = (() => {}) as any;
|
||||
ctx.arc = (() => {}) as any;
|
||||
ctx.setLineDash = (() => {}) as any;
|
||||
ctx.clearRect = (() => {}) as any;
|
||||
ctx.translate = (() => {}) as any;
|
||||
ctx.rotate = (() => {}) as any;
|
||||
ctx.clip = (() => {}) as any;
|
||||
ctx.fillText = (() => {}) as any;
|
||||
ctx.quadraticCurveTo = (() => {}) as any;
|
||||
ctx.closePath = (() => {}) as any;
|
||||
}
|
||||
return canvas;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
function makeLine(id: string, x1: number, y1: number, x2: number, y2: number, layerId = 'layer-1'): 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 },
|
||||
};
|
||||
}
|
||||
|
||||
function makeRect(id: string, cx: number, cy: number, w: number, h: number, layerId = 'layer-1'): CADElement {
|
||||
return {
|
||||
id,
|
||||
type: 'rect',
|
||||
layerId,
|
||||
x: cx,
|
||||
y: cy,
|
||||
width: w,
|
||||
height: h,
|
||||
properties: {},
|
||||
};
|
||||
}
|
||||
|
||||
function setup(): {
|
||||
canvas: HTMLCanvasElement;
|
||||
zpc: ZoomPanController;
|
||||
spatialIndex: SpatialIndex;
|
||||
layerManager: LayerManager;
|
||||
renderEngine: RenderEngine;
|
||||
selectionEngine: SelectionEngine;
|
||||
} {
|
||||
const canvas = createMockCanvas(800, 600);
|
||||
const zpc = new ZoomPanController(canvas);
|
||||
const spatialIndex = new SpatialIndex();
|
||||
const layerManager = new LayerManager();
|
||||
layerManager.addLayer(makeLayer('layer-1'));
|
||||
const renderEngine = new RenderEngine(canvas, zpc, spatialIndex, layerManager);
|
||||
const selectionEngine = new SelectionEngine(renderEngine, spatialIndex, layerManager);
|
||||
return { canvas, zpc, spatialIndex, layerManager, renderEngine, selectionEngine };
|
||||
}
|
||||
|
||||
describe('SelectionEngine', () => {
|
||||
let s: ReturnType<typeof setup>;
|
||||
|
||||
beforeEach(() => {
|
||||
s = setup();
|
||||
});
|
||||
|
||||
describe('initial state', () => {
|
||||
it('should have empty selection', () => {
|
||||
expect(s.selectionEngine.getSelectedIds().size).toBe(0);
|
||||
});
|
||||
|
||||
it('should have default options (single mode, all filter)', () => {
|
||||
const opts = s.selectionEngine.getOptions();
|
||||
expect(opts.mode).toBe('single');
|
||||
expect(opts.filter).toBe('all');
|
||||
expect(opts.additive).toBe(false);
|
||||
expect(opts.subtractive).toBe(false);
|
||||
});
|
||||
|
||||
it('should have null hover', () => {
|
||||
expect(s.selectionEngine.getHoverId()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setOptions', () => {
|
||||
it('should update options partially', () => {
|
||||
s.selectionEngine.setOptions({ filter: 'lines' });
|
||||
expect(s.selectionEngine.getOptions().filter).toBe('lines');
|
||||
});
|
||||
});
|
||||
|
||||
describe('clickSelect – hit', () => {
|
||||
it('should select an element when clicking on it', () => {
|
||||
const line = makeLine('l1', 50, 50, 150, 50);
|
||||
s.spatialIndex.insert(line);
|
||||
const result = s.selectionEngine.clickSelect(100, 50, [line]);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.id).toBe('l1');
|
||||
expect(s.selectionEngine.getSelectedIds().has('l1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should select a rect element when clicking within it', () => {
|
||||
const rect = makeRect('r1', 100, 100, 80, 60);
|
||||
s.spatialIndex.insert(rect);
|
||||
const result = s.selectionEngine.clickSelect(100, 100, [rect]);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.id).toBe('r1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('clickSelect – miss', () => {
|
||||
it('should return null when clicking empty space', () => {
|
||||
const line = makeLine('l1', 50, 50, 150, 50);
|
||||
s.spatialIndex.insert(line);
|
||||
const result = s.selectionEngine.clickSelect(400, 400, [line]);
|
||||
expect(result).toBeNull();
|
||||
expect(s.selectionEngine.getSelectedIds().size).toBe(0);
|
||||
});
|
||||
|
||||
it('should clear selection when clicking empty space in non-additive mode', () => {
|
||||
const line = makeLine('l1', 50, 50, 150, 50);
|
||||
s.spatialIndex.insert(line);
|
||||
s.selectionEngine.clickSelect(100, 50, [line]);
|
||||
expect(s.selectionEngine.getSelectedIds().size).toBe(1);
|
||||
s.selectionEngine.clickSelect(400, 400, [line]);
|
||||
expect(s.selectionEngine.getSelectedIds().size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clickSelect – additive mode', () => {
|
||||
it('should add to selection in additive mode', () => {
|
||||
const l1 = makeLine('l1', 50, 50, 150, 50);
|
||||
const l2 = makeLine('l2', 50, 100, 150, 100);
|
||||
s.spatialIndex.insert(l1);
|
||||
s.spatialIndex.insert(l2);
|
||||
s.selectionEngine.setOptions({ additive: true });
|
||||
s.selectionEngine.clickSelect(100, 50, [l1, l2]);
|
||||
s.selectionEngine.clickSelect(100, 100, [l1, l2]);
|
||||
expect(s.selectionEngine.getSelectedIds().size).toBe(2);
|
||||
});
|
||||
|
||||
it('should not clear on miss in additive mode', () => {
|
||||
const l1 = makeLine('l1', 50, 50, 150, 50);
|
||||
s.spatialIndex.insert(l1);
|
||||
s.selectionEngine.setOptions({ additive: true });
|
||||
s.selectionEngine.clickSelect(100, 50, [l1]);
|
||||
s.selectionEngine.clickSelect(400, 400, [l1]);
|
||||
expect(s.selectionEngine.getSelectedIds().size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clickSelect – subtractive mode', () => {
|
||||
it('should remove from selection in subtractive mode', () => {
|
||||
const l1 = makeLine('l1', 50, 50, 150, 50);
|
||||
s.spatialIndex.insert(l1);
|
||||
// First select normally
|
||||
s.selectionEngine.clickSelect(100, 50, [l1]);
|
||||
expect(s.selectionEngine.getSelectedIds().has('l1')).toBe(true);
|
||||
// Now subtract
|
||||
s.selectionEngine.setOptions({ subtractive: true });
|
||||
s.selectionEngine.clickSelect(100, 50, [l1]);
|
||||
expect(s.selectionEngine.getSelectedIds().has('l1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clickSelect – filter modes', () => {
|
||||
it('should not select when filter does not match', () => {
|
||||
const rect = makeRect('r1', 100, 100, 80, 60);
|
||||
s.spatialIndex.insert(rect);
|
||||
s.selectionEngine.setOptions({ filter: 'lines' });
|
||||
const result = s.selectionEngine.clickSelect(100, 100, [rect]);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should select when filter matches lines', () => {
|
||||
const line = makeLine('l1', 50, 50, 150, 50);
|
||||
s.spatialIndex.insert(line);
|
||||
s.selectionEngine.setOptions({ filter: 'lines' });
|
||||
const result = s.selectionEngine.clickSelect(100, 50, [line]);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.id).toBe('l1');
|
||||
});
|
||||
|
||||
it('should select rects with rects filter', () => {
|
||||
const rect = makeRect('r1', 100, 100, 80, 60);
|
||||
s.spatialIndex.insert(rect);
|
||||
s.selectionEngine.setOptions({ filter: 'rects' });
|
||||
const result = s.selectionEngine.clickSelect(100, 100, [rect]);
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('box selection', () => {
|
||||
it('should select elements within box (window mode)', () => {
|
||||
const r1 = makeRect('r1', 100, 100, 40, 40);
|
||||
const r2 = makeRect('r2', 300, 300, 40, 40);
|
||||
s.spatialIndex.insert(r1);
|
||||
s.spatialIndex.insert(r2);
|
||||
// Window: left-to-right, fully enclosed
|
||||
s.selectionEngine.startBoxSelect(50, 50);
|
||||
s.selectionEngine.updateBoxSelect(200, 200, [r1, r2]);
|
||||
const selected = s.selectionEngine.finishBoxSelect([r1, r2]);
|
||||
// r1 bbox: 80,80 to 120,120 — fully within 50,50 to 200,200
|
||||
expect(selected.length).toBe(1);
|
||||
expect(selected[0].id).toBe('r1');
|
||||
});
|
||||
|
||||
it('should select elements intersecting box (crossing mode)', () => {
|
||||
const r1 = makeRect('r1', 100, 100, 40, 40);
|
||||
s.spatialIndex.insert(r1);
|
||||
// Crossing: right-to-left (start.x > end.x)
|
||||
s.selectionEngine.startBoxSelect(200, 200);
|
||||
s.selectionEngine.updateBoxSelect(90, 90, [r1]);
|
||||
const selected = s.selectionEngine.finishBoxSelect([r1]);
|
||||
expect(selected.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should clear box start/end after finish', () => {
|
||||
s.selectionEngine.startBoxSelect(50, 50);
|
||||
s.selectionEngine.updateBoxSelect(100, 100, []);
|
||||
s.selectionEngine.finishBoxSelect([]);
|
||||
expect(s.selectionEngine.isBoxSelecting()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return empty when no box started', () => {
|
||||
const result = s.selectionEngine.finishBoxSelect([]);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('cancelBoxSelect should stop box selection', () => {
|
||||
s.selectionEngine.startBoxSelect(50, 50);
|
||||
s.selectionEngine.cancelBoxSelect();
|
||||
expect(s.selectionEngine.isBoxSelecting()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearSelection', () => {
|
||||
it('should clear all selected ids', () => {
|
||||
const l1 = makeLine('l1', 50, 50, 150, 50);
|
||||
s.spatialIndex.insert(l1);
|
||||
s.selectionEngine.clickSelect(100, 50, [l1]);
|
||||
s.selectionEngine.clearSelection();
|
||||
expect(s.selectionEngine.getSelectedIds().size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hover', () => {
|
||||
it('should set and get hover id', () => {
|
||||
s.selectionEngine.setHover('el-1');
|
||||
expect(s.selectionEngine.getHoverId()).toBe('el-1');
|
||||
});
|
||||
|
||||
it('should clear hover with null', () => {
|
||||
s.selectionEngine.setHover('el-1');
|
||||
s.selectionEngine.setHover(null);
|
||||
expect(s.selectionEngine.getHoverId()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectByIds', () => {
|
||||
it('should select by ids', () => {
|
||||
s.selectionEngine.selectByIds(['a', 'b', 'c']);
|
||||
expect(s.selectionEngine.getSelectedIds().size).toBe(3);
|
||||
});
|
||||
|
||||
it('should add to selection when additive=true', () => {
|
||||
s.selectionEngine.selectByIds(['a']);
|
||||
s.selectionEngine.selectByIds(['b'], true);
|
||||
expect(s.selectionEngine.getSelectedIds().size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectAll', () => {
|
||||
it('should select all visible elements matching filter', () => {
|
||||
const l1 = makeLine('l1', 50, 50, 150, 50);
|
||||
const l2 = makeLine('l2', 50, 100, 150, 100);
|
||||
s.spatialIndex.insert(l1);
|
||||
s.spatialIndex.insert(l2);
|
||||
s.selectionEngine.selectAll([l1, l2]);
|
||||
expect(s.selectionEngine.getSelectedIds().size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invertSelection', () => {
|
||||
it('should invert current selection', () => {
|
||||
const l1 = makeLine('l1', 50, 50, 150, 50);
|
||||
const l2 = makeLine('l2', 50, 100, 150, 100);
|
||||
s.spatialIndex.insert(l1);
|
||||
s.spatialIndex.insert(l2);
|
||||
s.selectionEngine.selectByIds(['l1']);
|
||||
s.selectionEngine.invertSelection([l1, l2]);
|
||||
const ids = s.selectionEngine.getSelectedIds();
|
||||
expect(ids.has('l1')).toBe(false);
|
||||
expect(ids.has('l2')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('quickSelect', () => {
|
||||
it('should select by type', () => {
|
||||
const l1 = makeLine('l1', 50, 50, 150, 50);
|
||||
const r1 = makeRect('r1', 100, 100, 80, 60);
|
||||
const result = s.selectionEngine.quickSelect([l1, r1], { type: 'line' });
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].id).toBe('l1');
|
||||
});
|
||||
|
||||
it('should select by layerId', () => {
|
||||
const l1 = makeLine('l1', 50, 50, 150, 50, 'layer-1');
|
||||
const l2 = makeLine('l2', 50, 100, 150, 100, 'layer-2');
|
||||
const result = s.selectionEngine.quickSelect([l1, l2], { layerId: 'layer-1' });
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].id).toBe('l1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('listeners', () => {
|
||||
it('should call listener on selection change', () => {
|
||||
let called = false;
|
||||
let received: CADElement[] = [];
|
||||
s.selectionEngine.addListener((els) => {
|
||||
called = true;
|
||||
received = els;
|
||||
});
|
||||
const l1 = makeLine('l1', 50, 50, 150, 50);
|
||||
s.spatialIndex.insert(l1);
|
||||
s.selectionEngine.clickSelect(100, 50, [l1]);
|
||||
expect(called).toBe(true);
|
||||
expect(received.length).toBe(1);
|
||||
expect(received[0].id).toBe('l1');
|
||||
});
|
||||
|
||||
it('should remove listener', () => {
|
||||
let called = false;
|
||||
const fn = (els: CADElement[]) => { called = true; };
|
||||
s.selectionEngine.addListener(fn);
|
||||
s.selectionEngine.removeListener(fn);
|
||||
const l1 = makeLine('l1', 50, 50, 150, 50);
|
||||
s.spatialIndex.insert(l1);
|
||||
s.selectionEngine.clickSelect(100, 50, [l1]);
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSelectedElements', () => {
|
||||
it('should filter allElements by selected ids', () => {
|
||||
const l1 = makeLine('l1', 50, 50, 150, 50);
|
||||
const l2 = makeLine('l2', 50, 100, 150, 100);
|
||||
s.selectionEngine.selectByIds(['l1']);
|
||||
const result = s.selectionEngine.getSelectedElements([l1, l2]);
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].id).toBe('l1');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* SnapEngine Tests – Snapping modes, grid snap, polar tracking
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { SnapEngine } from '../src/canvas/SnapEngine';
|
||||
import type { SnapMode, SnapConfig } from '../src/canvas/SnapEngine';
|
||||
import type { CADElement } from '../src/types/cad.types';
|
||||
|
||||
function makeLine(id: string, x1: number, y1: number, x2: number, y2: number): CADElement {
|
||||
const cx = (x1 + x2) / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
return {
|
||||
id,
|
||||
type: 'line',
|
||||
layerId: 'layer-1',
|
||||
x: cx,
|
||||
y: cy,
|
||||
width: Math.abs(x2 - x1),
|
||||
height: Math.abs(y2 - y1),
|
||||
properties: { x1, y1, x2, y2 },
|
||||
};
|
||||
}
|
||||
|
||||
function makeCircle(id: string, cx: number, cy: number, r: number): CADElement {
|
||||
return {
|
||||
id,
|
||||
type: 'circle',
|
||||
layerId: 'layer-1',
|
||||
x: cx,
|
||||
y: cy,
|
||||
width: r * 2,
|
||||
height: r * 2,
|
||||
properties: { radius: r },
|
||||
};
|
||||
}
|
||||
|
||||
function makeRect(id: string, cx: number, cy: number, w: number, h: number): CADElement {
|
||||
return {
|
||||
id,
|
||||
type: 'rect',
|
||||
layerId: 'layer-1',
|
||||
x: cx,
|
||||
y: cy,
|
||||
width: w,
|
||||
height: h,
|
||||
properties: {},
|
||||
};
|
||||
}
|
||||
|
||||
describe('SnapEngine', () => {
|
||||
let engine: SnapEngine;
|
||||
|
||||
beforeEach(() => {
|
||||
engine = new SnapEngine();
|
||||
});
|
||||
|
||||
describe('constructor & config', () => {
|
||||
it('should have default config with enabled=true', () => {
|
||||
const cfg = engine.getConfig();
|
||||
expect(cfg.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should have default modes including endpoint, midpoint, center, intersection, nearest', () => {
|
||||
const cfg = engine.getConfig();
|
||||
expect(cfg.modes.has('endpoint')).toBe(true);
|
||||
expect(cfg.modes.has('midpoint')).toBe(true);
|
||||
expect(cfg.modes.has('center')).toBe(true);
|
||||
expect(cfg.modes.has('intersection')).toBe(true);
|
||||
expect(cfg.modes.has('nearest')).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept custom config overrides', () => {
|
||||
const eng = new SnapEngine({ tolerance: 25, gridSpacing: 50 });
|
||||
const cfg = eng.getConfig();
|
||||
expect(cfg.tolerance).toBe(25);
|
||||
expect(cfg.gridSpacing).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setConfig & toggleMode', () => {
|
||||
it('should update config partially', () => {
|
||||
engine.setConfig({ tolerance: 30 });
|
||||
expect(engine.getConfig().tolerance).toBe(30);
|
||||
});
|
||||
|
||||
it('should toggle mode on/off', () => {
|
||||
expect(engine.getConfig().modes.has('endpoint')).toBe(true);
|
||||
engine.toggleMode('endpoint');
|
||||
expect(engine.getConfig().modes.has('endpoint')).toBe(false);
|
||||
engine.toggleMode('endpoint');
|
||||
expect(engine.getConfig().modes.has('endpoint')).toBe(true);
|
||||
});
|
||||
|
||||
it('should toggle grid mode on', () => {
|
||||
engine.toggleMode('grid');
|
||||
expect(engine.getConfig().modes.has('grid')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('snap – disabled', () => {
|
||||
it('should return null point when disabled', () => {
|
||||
engine.setConfig({ enabled: false });
|
||||
engine.setElements([makeLine('l1', 0, 0, 100, 0)]);
|
||||
const result = engine.snap(0, 0);
|
||||
expect(result.point).toBeNull();
|
||||
expect(result.preview).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return null point when no modes are active', () => {
|
||||
const eng = new SnapEngine({
|
||||
modes: new Set<SnapMode>(),
|
||||
});
|
||||
eng.setElements([makeLine('l1', 0, 0, 100, 0)]);
|
||||
const result = eng.snap(0, 0);
|
||||
expect(result.point).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('snap – endpoint mode', () => {
|
||||
it('should snap to line endpoint when within tolerance', () => {
|
||||
engine.setElements([makeLine('l1', 0, 0, 100, 0)]);
|
||||
const result = engine.snap(2, 2);
|
||||
expect(result.point).not.toBeNull();
|
||||
expect(result.point!.x).toBe(0);
|
||||
expect(result.point!.y).toBe(0);
|
||||
expect(result.point!.type).toBe('endpoint');
|
||||
});
|
||||
|
||||
it('should snap to the second endpoint of a line', () => {
|
||||
engine.setElements([makeLine('l1', 0, 0, 100, 0)]);
|
||||
const result = engine.snap(98, 1);
|
||||
expect(result.point).not.toBeNull();
|
||||
expect(result.point!.x).toBe(100);
|
||||
expect(result.point!.y).toBe(0);
|
||||
expect(result.point!.type).toBe('endpoint');
|
||||
});
|
||||
|
||||
it('should not snap when outside tolerance', () => {
|
||||
engine.setElements([makeLine('l1', 0, 0, 100, 0)]);
|
||||
engine.setConfig({ tolerance: 5 });
|
||||
const result = engine.snap(50, 20);
|
||||
expect(result.point).toBeNull();
|
||||
});
|
||||
|
||||
it('should snap to rect corners', () => {
|
||||
engine.setElements([makeRect('r1', 50, 50, 40, 40)]);
|
||||
const result = engine.snap(32, 32);
|
||||
expect(result.point).not.toBeNull();
|
||||
expect(result.point!.x).toBe(30);
|
||||
expect(result.point!.y).toBe(30);
|
||||
expect(result.point!.type).toBe('endpoint');
|
||||
});
|
||||
});
|
||||
|
||||
describe('snap – midpoint mode', () => {
|
||||
it('should snap to line midpoint', () => {
|
||||
engine.setConfig({ modes: new Set<SnapMode>(['midpoint']) });
|
||||
engine.setElements([makeLine('l1', 0, 0, 100, 0)]);
|
||||
const result = engine.snap(50, 2);
|
||||
expect(result.point).not.toBeNull();
|
||||
expect(result.point!.x).toBe(50);
|
||||
expect(result.point!.y).toBe(0);
|
||||
expect(result.point!.type).toBe('midpoint');
|
||||
});
|
||||
|
||||
it('should snap to rect edge midpoints', () => {
|
||||
engine.setConfig({ modes: new Set<SnapMode>(['midpoint']) });
|
||||
engine.setElements([makeRect('r1', 50, 50, 40, 40)]);
|
||||
const result = engine.snap(50, 31);
|
||||
expect(result.point).not.toBeNull();
|
||||
expect(result.point!.x).toBe(50);
|
||||
expect(result.point!.y).toBe(30);
|
||||
expect(result.point!.type).toBe('midpoint');
|
||||
});
|
||||
});
|
||||
|
||||
describe('snap – center mode', () => {
|
||||
it('should snap to circle center', () => {
|
||||
engine.setConfig({ modes: new Set<SnapMode>(['center']) });
|
||||
engine.setElements([makeCircle('c1', 50, 50, 30)]);
|
||||
const result = engine.snap(52, 48);
|
||||
expect(result.point).not.toBeNull();
|
||||
expect(result.point!.x).toBe(50);
|
||||
expect(result.point!.y).toBe(50);
|
||||
expect(result.point!.type).toBe('center');
|
||||
});
|
||||
|
||||
it('should snap to rect center', () => {
|
||||
engine.setConfig({ modes: new Set<SnapMode>(['center']) });
|
||||
engine.setElements([makeRect('r1', 50, 50, 40, 40)]);
|
||||
const result = engine.snap(48, 52);
|
||||
expect(result.point).not.toBeNull();
|
||||
expect(result.point!.x).toBe(50);
|
||||
expect(result.point!.y).toBe(50);
|
||||
expect(result.point!.type).toBe('center');
|
||||
});
|
||||
});
|
||||
|
||||
describe('snap – grid mode', () => {
|
||||
it('should snap to nearest grid point', () => {
|
||||
engine.setConfig({
|
||||
modes: new Set<SnapMode>(['grid']),
|
||||
gridSpacing: 20,
|
||||
tolerance: 10,
|
||||
});
|
||||
const result = engine.snap(18, 2);
|
||||
expect(result.point).not.toBeNull();
|
||||
expect(result.point!.x).toBe(20);
|
||||
expect(result.point!.y).toBe(0);
|
||||
});
|
||||
|
||||
it('should snap to grid point at origin', () => {
|
||||
engine.setConfig({
|
||||
modes: new Set<SnapMode>(['grid']),
|
||||
gridSpacing: 20,
|
||||
tolerance: 10,
|
||||
});
|
||||
const result = engine.snap(3, 3);
|
||||
expect(result.point).not.toBeNull();
|
||||
expect(result.point!.x).toBe(0);
|
||||
expect(result.point!.y).toBe(0);
|
||||
});
|
||||
|
||||
it('should not snap when too far from grid point', () => {
|
||||
engine.setConfig({
|
||||
modes: new Set<SnapMode>(['grid']),
|
||||
gridSpacing: 20,
|
||||
tolerance: 5,
|
||||
});
|
||||
const result = engine.snap(13, 13);
|
||||
expect(result.point).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('snap – nearest mode', () => {
|
||||
it('should snap to nearest point on a line', () => {
|
||||
engine.setConfig({ modes: new Set<SnapMode>(['nearest']) });
|
||||
engine.setElements([makeLine('l1', 0, 0, 100, 0)]);
|
||||
const result = engine.snap(50, 5);
|
||||
expect(result.point).not.toBeNull();
|
||||
expect(result.point!.x).toBe(50);
|
||||
expect(result.point!.y).toBe(0);
|
||||
expect(result.point!.type).toBe('nearest');
|
||||
});
|
||||
});
|
||||
|
||||
describe('snap – intersection mode', () => {
|
||||
it('should snap to intersection of two lines', () => {
|
||||
engine.setConfig({ modes: new Set<SnapMode>(['intersection']), tolerance: 10 });
|
||||
engine.setElements([
|
||||
makeLine('l1', 0, 0, 100, 100),
|
||||
makeLine('l2', 0, 100, 100, 0),
|
||||
]);
|
||||
const result = engine.snap(52, 50);
|
||||
expect(result.point).not.toBeNull();
|
||||
expect(result.point!.x).toBeCloseTo(50, 0);
|
||||
expect(result.point!.y).toBeCloseTo(50, 0);
|
||||
expect(result.point!.type).toBe('intersection');
|
||||
});
|
||||
});
|
||||
|
||||
describe('snap – priority', () => {
|
||||
it('should prefer endpoint over grid when both are in range', () => {
|
||||
engine.setConfig({
|
||||
modes: new Set<SnapMode>(['endpoint', 'grid']),
|
||||
gridSpacing: 20,
|
||||
tolerance: 10,
|
||||
});
|
||||
engine.setElements([makeLine('l1', 0, 0, 100, 0)]);
|
||||
const result = engine.snap(2, 2);
|
||||
expect(result.point).not.toBeNull();
|
||||
expect(result.point!.type).toBe('endpoint');
|
||||
});
|
||||
});
|
||||
|
||||
describe('snap – preview', () => {
|
||||
it('should return preview candidates', () => {
|
||||
engine.setElements([makeLine('l1', 0, 0, 100, 0)]);
|
||||
const result = engine.snap(2, 2);
|
||||
expect(result.preview.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('polar tracking', () => {
|
||||
it('should snap to 0-degree polar angle from reference point', () => {
|
||||
engine.setConfig({
|
||||
polarEnabled: true,
|
||||
polarAngles: [0, 90, 180, 270],
|
||||
polarTolerance: 5,
|
||||
modes: new Set<SnapMode>(['nearest']),
|
||||
});
|
||||
// Reference at (0,0), cursor near 0 degrees (horizontal right) at distance ~100
|
||||
const result = engine.snap(100, 5, { x: 0, y: 0 });
|
||||
expect(result.point).not.toBeNull();
|
||||
if (result.point) {
|
||||
expect(result.point.y).toBeCloseTo(0, 0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should snap to 90-degree polar angle from reference point', () => {
|
||||
engine.setConfig({
|
||||
polarEnabled: true,
|
||||
polarAngles: [0, 90, 180, 270],
|
||||
polarTolerance: 5,
|
||||
modes: new Set<SnapMode>(['nearest']),
|
||||
});
|
||||
// Reference at (0,0), cursor near 90 degrees (down) at distance ~100
|
||||
const result = engine.snap(5, 100, { x: 0, y: 0 });
|
||||
expect(result.point).not.toBeNull();
|
||||
if (result.point) {
|
||||
expect(result.point.x).toBeCloseTo(0, 0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should not polar-snap when polarEnabled is false', () => {
|
||||
engine.setConfig({
|
||||
polarEnabled: false,
|
||||
modes: new Set<SnapMode>(['nearest']),
|
||||
});
|
||||
engine.setElements([makeLine('l1', 0, 0, 200, 0)]);
|
||||
const result = engine.snap(100, 5, { x: 0, y: 0 });
|
||||
// Should snap to nearest on line, not polar
|
||||
if (result.point) {
|
||||
expect(result.point.y).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should not polar-snap when cursor too close to reference', () => {
|
||||
engine.setConfig({
|
||||
polarEnabled: true,
|
||||
polarAngles: [0],
|
||||
polarTolerance: 5,
|
||||
modes: new Set<SnapMode>(['nearest']),
|
||||
});
|
||||
const result = engine.snap(0.5, 0.5, { x: 0, y: 0 });
|
||||
// dist < 1, so no polar snap; no elements either, so null
|
||||
expect(result.point).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Stresstest – 50.000 Elemente: SpatialIndex Query-Performance,
|
||||
* HistoryManager Memory, Undo/Redo Performance.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SpatialIndex } from '../src/canvas/SpatialIndex';
|
||||
import { HistoryManager } from '../src/history/HistoryManager';
|
||||
import type { CADElement, CADLayer } from '../src/types/cad.types';
|
||||
|
||||
const N = 50_000;
|
||||
|
||||
function generateElements(count: number): CADElement[] {
|
||||
const elements: CADElement[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
elements.push({
|
||||
id: `elem-${i}`,
|
||||
type: 'rect',
|
||||
layerId: 'layer-1',
|
||||
x: (i % 1000) * 1.5,
|
||||
y: Math.floor(i / 1000) * 1.5,
|
||||
width: 1,
|
||||
height: 1,
|
||||
properties: {},
|
||||
});
|
||||
}
|
||||
return elements;
|
||||
}
|
||||
|
||||
function makeLayer(): CADLayer {
|
||||
return {
|
||||
id: 'layer-1',
|
||||
name: 'Layer 1',
|
||||
visible: true,
|
||||
locked: false,
|
||||
color: '#ffffff',
|
||||
lineType: 'solid',
|
||||
transparency: 0,
|
||||
sortOrder: 0,
|
||||
parentId: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Stresstest: 50.000 Elemente', () => {
|
||||
it('should bulk-insert 50k elements into SpatialIndex in under 2s', () => {
|
||||
const elements = generateElements(N);
|
||||
const index = new SpatialIndex();
|
||||
const start = performance.now();
|
||||
index.bulkInsert(elements);
|
||||
const elapsed = performance.now() - start;
|
||||
console.log(`SpatialIndex bulkInsert: ${elapsed.toFixed(1)}ms for ${N} elements`);
|
||||
expect(elapsed).toBeLessThan(2000);
|
||||
});
|
||||
|
||||
it('should search SpatialIndex with 50k elements in under 10ms', () => {
|
||||
const elements = generateElements(N);
|
||||
const index = new SpatialIndex();
|
||||
index.bulkInsert(elements);
|
||||
// Search a region in the middle
|
||||
const start = performance.now();
|
||||
const results = index.search({ minX: 750, minY: 37.5, maxX: 1500, maxY: 75 });
|
||||
const elapsed = performance.now() - start;
|
||||
console.log(`SpatialIndex search: ${elapsed.toFixed(2)}ms, found ${results.length} elements`);
|
||||
expect(elapsed).toBeLessThan(50);
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should search a small point region with 50k elements in under 5ms', () => {
|
||||
const elements = generateElements(N);
|
||||
const index = new SpatialIndex();
|
||||
index.bulkInsert(elements);
|
||||
const start = performance.now();
|
||||
const results = index.search({ minX: 750, minY: 37.5, maxX: 751, maxY: 38.5 });
|
||||
const elapsed = performance.now() - start;
|
||||
console.log(`SpatialIndex point search: ${elapsed.toFixed(3)}ms, found ${results.length} elements`);
|
||||
expect(elapsed).toBeLessThan(10);
|
||||
});
|
||||
|
||||
it('should clear and re-insert 50k elements in under 2s', () => {
|
||||
const elements = generateElements(N);
|
||||
const index = new SpatialIndex();
|
||||
index.bulkInsert(elements);
|
||||
// Modify 100 elements
|
||||
for (let i = 0; i < 100; i++) {
|
||||
elements[i].x += 5000;
|
||||
}
|
||||
const start = performance.now();
|
||||
index.clear();
|
||||
index.bulkInsert(elements);
|
||||
const elapsed = performance.now() - start;
|
||||
console.log(`SpatialIndex clear+reinsert: ${elapsed.toFixed(1)}ms after 100 modifications`);
|
||||
expect(elapsed).toBeLessThan(2000);
|
||||
});
|
||||
|
||||
it('should handle HistoryManager with 50k elements snapshot', () => {
|
||||
const elements = generateElements(N);
|
||||
const layers = [makeLayer()];
|
||||
const history = new HistoryManager({ maxStackSize: 50 });
|
||||
const start = performance.now();
|
||||
history.initialize({
|
||||
elements,
|
||||
layers,
|
||||
blocks: [],
|
||||
groups: [],
|
||||
bgConfig: null,
|
||||
});
|
||||
const elapsed = performance.now() - start;
|
||||
console.log(`HistoryManager initialize: ${elapsed.toFixed(1)}ms for ${N} elements`);
|
||||
expect(elapsed).toBeLessThan(1000);
|
||||
});
|
||||
|
||||
it('should push 10 history snapshots with 50k elements each', () => {
|
||||
const elements = generateElements(N);
|
||||
const layers = [makeLayer()];
|
||||
const history = new HistoryManager({ maxStackSize: 50 });
|
||||
history.initialize({
|
||||
elements,
|
||||
layers,
|
||||
blocks: [],
|
||||
groups: [],
|
||||
bgConfig: null,
|
||||
});
|
||||
const start = performance.now();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
// Slightly modify elements each snapshot
|
||||
elements[i * 100].x += 1;
|
||||
history.pushSnapshot({
|
||||
elements: [...elements],
|
||||
layers,
|
||||
blocks: [],
|
||||
groups: [],
|
||||
bgConfig: null,
|
||||
label: `Step ${i + 1}`,
|
||||
});
|
||||
}
|
||||
const elapsed = performance.now() - start;
|
||||
console.log(`HistoryManager 10 snapshots: ${elapsed.toFixed(1)}ms for ${N} elements each`);
|
||||
expect(elapsed).toBeLessThan(5000);
|
||||
expect(history.getHistory().length).toBe(11); // initial + 10
|
||||
});
|
||||
|
||||
it('should undo/redo with 50k elements in under 100ms', () => {
|
||||
const elements = generateElements(N);
|
||||
const layers = [makeLayer()];
|
||||
const history = new HistoryManager({ maxStackSize: 50 });
|
||||
history.initialize({
|
||||
elements,
|
||||
layers,
|
||||
blocks: [],
|
||||
groups: [],
|
||||
bgConfig: null,
|
||||
});
|
||||
history.pushSnapshot({
|
||||
elements: [...elements],
|
||||
layers,
|
||||
blocks: [],
|
||||
groups: [],
|
||||
bgConfig: null,
|
||||
label: 'Step 1',
|
||||
});
|
||||
const undoStart = performance.now();
|
||||
const undoSnap = history.undo();
|
||||
const undoTime = performance.now() - undoStart;
|
||||
console.log(`Undo: ${undoTime.toFixed(2)}ms for ${N} elements`);
|
||||
expect(undoSnap).not.toBeNull();
|
||||
expect(undoTime).toBeLessThan(100);
|
||||
const redoStart = performance.now();
|
||||
const redoSnap = history.redo();
|
||||
const redoTime = performance.now() - redoStart;
|
||||
console.log(`Redo: ${redoTime.toFixed(2)}ms for ${N} elements`);
|
||||
expect(redoSnap).not.toBeNull();
|
||||
expect(redoTime).toBeLessThan(100);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* ZoomPanController Tests – Zoom & Pan Transformation
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { ZoomPanController } from '../src/canvas/ZoomPanController';
|
||||
|
||||
function createMockCanvas(w = 800, h = 600): HTMLCanvasElement {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.fillRect = (() => {}) as any;
|
||||
ctx.strokeRect = (() => {}) as any;
|
||||
ctx.beginPath = (() => {}) as any;
|
||||
ctx.moveTo = (() => {}) as any;
|
||||
ctx.lineTo = (() => {}) as any;
|
||||
ctx.stroke = (() => {}) as any;
|
||||
ctx.fill = (() => {}) as any;
|
||||
ctx.save = (() => {}) as any;
|
||||
ctx.restore = (() => {}) as any;
|
||||
ctx.scale = (() => {}) as any;
|
||||
ctx.arc = (() => {}) as any;
|
||||
ctx.setLineDash = (() => {}) as any;
|
||||
ctx.clearRect = (() => {}) as any;
|
||||
ctx.translate = (() => {}) as any;
|
||||
ctx.rotate = (() => {}) as any;
|
||||
}
|
||||
return canvas;
|
||||
}
|
||||
|
||||
describe('ZoomPanController', () => {
|
||||
let canvas: HTMLCanvasElement;
|
||||
let zpc: ZoomPanController;
|
||||
|
||||
beforeEach(() => {
|
||||
canvas = createMockCanvas(800, 600);
|
||||
zpc = new ZoomPanController(canvas);
|
||||
});
|
||||
|
||||
describe('initial state', () => {
|
||||
it('should have scale=1, offsetX=0, offsetY=0', () => {
|
||||
const t = zpc.getTransform();
|
||||
expect(t.a).toBe(1);
|
||||
expect(t.d).toBe(1);
|
||||
expect(t.e).toBe(0);
|
||||
expect(t.f).toBe(0);
|
||||
});
|
||||
|
||||
it('getScale should return 1 initially', () => {
|
||||
expect(zpc.getScale()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getViewport', () => {
|
||||
it('should return viewport covering full canvas at scale=1', () => {
|
||||
const vp = zpc.getViewport();
|
||||
expect(vp.minX).toBe(0);
|
||||
expect(vp.minY).toBe(0);
|
||||
expect(vp.maxX).toBe(800);
|
||||
expect(vp.maxY).toBe(600);
|
||||
});
|
||||
|
||||
it('should shrink visible area when zoomed in', () => {
|
||||
zpc.zoomAt(400, 300, 2);
|
||||
const vp = zpc.getViewport();
|
||||
const w = vp.maxX - vp.minX;
|
||||
const h = vp.maxY - vp.minY;
|
||||
expect(w).toBeCloseTo(400, 1);
|
||||
expect(h).toBeCloseTo(300, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('zoomAt', () => {
|
||||
it('should increase scale with factor > 1', () => {
|
||||
zpc.zoomAt(100, 100, 2);
|
||||
expect(zpc.getScale()).toBe(2);
|
||||
});
|
||||
|
||||
it('should decrease scale with factor < 1', () => {
|
||||
zpc.zoomAt(100, 100, 0.5);
|
||||
expect(zpc.getScale()).toBe(0.5);
|
||||
});
|
||||
|
||||
it('should keep the zoom center point stable', () => {
|
||||
zpc.zoomAt(400, 300, 2);
|
||||
const screen = zpc.worldToScreen(400, 300);
|
||||
expect(screen.x).toBeCloseTo(400, 0);
|
||||
expect(screen.y).toBeCloseTo(300, 0);
|
||||
});
|
||||
|
||||
it('should clamp scale to minimum 0.01', () => {
|
||||
zpc.zoomAt(0, 0, 0.001);
|
||||
expect(zpc.getScale()).toBeGreaterThanOrEqual(0.01);
|
||||
});
|
||||
|
||||
it('should clamp scale to maximum 100', () => {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
zpc.zoomAt(400, 300, 10);
|
||||
}
|
||||
expect(zpc.getScale()).toBeLessThanOrEqual(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pan', () => {
|
||||
it('should update offsetX and offsetY', () => {
|
||||
zpc.pan(50, 30);
|
||||
const t = zpc.getTransform();
|
||||
expect(t.e).toBe(50);
|
||||
expect(t.f).toBe(30);
|
||||
});
|
||||
|
||||
it('should accumulate pan calls', () => {
|
||||
zpc.pan(10, 20);
|
||||
zpc.pan(30, 40);
|
||||
const t = zpc.getTransform();
|
||||
expect(t.e).toBe(40);
|
||||
expect(t.f).toBe(60);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('should restore scale=1 and offset=0,0', () => {
|
||||
zpc.zoomAt(100, 100, 3);
|
||||
zpc.pan(50, 50);
|
||||
zpc.reset();
|
||||
const t = zpc.getTransform();
|
||||
expect(t.a).toBe(1);
|
||||
expect(t.d).toBe(1);
|
||||
expect(t.e).toBe(0);
|
||||
expect(t.f).toBe(0);
|
||||
expect(zpc.getScale()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('worldToScreen & screenToWorld', () => {
|
||||
it('should convert world to screen at identity transform', () => {
|
||||
const s = zpc.worldToScreen(100, 200);
|
||||
expect(s.x).toBe(100);
|
||||
expect(s.y).toBe(200);
|
||||
});
|
||||
|
||||
it('should convert world to screen with scale and offset', () => {
|
||||
zpc.zoomAt(0, 0, 2);
|
||||
zpc.pan(50, 50);
|
||||
const s = zpc.worldToScreen(100, 100);
|
||||
expect(s.x).toBe(250);
|
||||
expect(s.y).toBe(250);
|
||||
});
|
||||
});
|
||||
|
||||
describe('zoomFit', () => {
|
||||
it('should not crash with empty elements', () => {
|
||||
zpc.zoomFit([]);
|
||||
expect(zpc.getScale()).toBe(1);
|
||||
});
|
||||
|
||||
it('should fit elements within canvas', () => {
|
||||
zpc.zoomFit([
|
||||
{ x: 0, y: 0, width: 200, height: 200 },
|
||||
{ x: 400, y: 400, width: 200, height: 200 },
|
||||
]);
|
||||
expect(zpc.getScale()).toBeGreaterThan(0);
|
||||
expect(zpc.getScale()).toBeLessThan(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('zoomToRect', () => {
|
||||
it('should zoom to a given rect', () => {
|
||||
zpc.zoomToRect({ minX: 0, minY: 0, maxX: 400, maxY: 300 });
|
||||
expect(zpc.getScale()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should do nothing for zero-size rect', () => {
|
||||
const before = zpc.getScale();
|
||||
zpc.zoomToRect({ minX: 0, minY: 0, maxX: 0, maxY: 0 });
|
||||
expect(zpc.getScale()).toBe(before);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* commandRegistry Tests – Command lookup, autocomplete, categories
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { CommandRegistry } from '../src/services/commandRegistry';
|
||||
import type { CommandDefinition } from '../src/services/commandRegistry';
|
||||
|
||||
describe('CommandRegistry', () => {
|
||||
let registry: CommandRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
registry = new CommandRegistry();
|
||||
});
|
||||
|
||||
describe('lookup by name', () => {
|
||||
it('should find LINE by name', () => {
|
||||
const cmd = registry.lookup('LINE');
|
||||
expect(cmd).not.toBeNull();
|
||||
expect(cmd!.name).toBe('LINE');
|
||||
expect(cmd!.toolId).toBe('line');
|
||||
});
|
||||
|
||||
it('should find CIRCLE by name', () => {
|
||||
const cmd = registry.lookup('CIRCLE');
|
||||
expect(cmd).not.toBeNull();
|
||||
expect(cmd!.name).toBe('CIRCLE');
|
||||
});
|
||||
|
||||
it('should find RECT by name', () => {
|
||||
const cmd = registry.lookup('RECT');
|
||||
expect(cmd).not.toBeNull();
|
||||
expect(cmd!.name).toBe('RECT');
|
||||
});
|
||||
|
||||
it('should find UNDO by name', () => {
|
||||
const cmd = registry.lookup('UNDO');
|
||||
expect(cmd).not.toBeNull();
|
||||
expect(cmd!.category).toBe('meta');
|
||||
});
|
||||
|
||||
it('should be case insensitive', () => {
|
||||
const cmd = registry.lookup('line');
|
||||
expect(cmd).not.toBeNull();
|
||||
expect(cmd!.name).toBe('LINE');
|
||||
});
|
||||
|
||||
it('should trim whitespace', () => {
|
||||
const cmd = registry.lookup(' LINE ');
|
||||
expect(cmd).not.toBeNull();
|
||||
expect(cmd!.name).toBe('LINE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('lookup by alias', () => {
|
||||
it('should find LINE by alias L', () => {
|
||||
const cmd = registry.lookup('L');
|
||||
expect(cmd).not.toBeNull();
|
||||
expect(cmd!.name).toBe('LINE');
|
||||
});
|
||||
|
||||
it('should find CIRCLE by alias C', () => {
|
||||
const cmd = registry.lookup('C');
|
||||
expect(cmd).not.toBeNull();
|
||||
expect(cmd!.name).toBe('CIRCLE');
|
||||
});
|
||||
|
||||
it('should find RECT by alias R', () => {
|
||||
const cmd = registry.lookup('R');
|
||||
expect(cmd).not.toBeNull();
|
||||
expect(cmd!.name).toBe('RECT');
|
||||
});
|
||||
|
||||
it('should find MOVE by alias M', () => {
|
||||
const cmd = registry.lookup('M');
|
||||
expect(cmd).not.toBeNull();
|
||||
expect(cmd!.name).toBe('MOVE');
|
||||
});
|
||||
|
||||
it('should find ERASE by alias E and DEL', () => {
|
||||
expect(registry.lookup('E')!.name).toBe('ERASE');
|
||||
expect(registry.lookup('DEL')!.name).toBe('ERASE');
|
||||
});
|
||||
|
||||
it('should find POLYLINE by alias PL', () => {
|
||||
const cmd = registry.lookup('PL');
|
||||
expect(cmd).not.toBeNull();
|
||||
expect(cmd!.name).toBe('POLYLINE');
|
||||
});
|
||||
|
||||
it('should find German alias LINIE for LINE', () => {
|
||||
const cmd = registry.lookup('LINIE');
|
||||
expect(cmd).not.toBeNull();
|
||||
expect(cmd!.name).toBe('LINE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('lookup unknown command', () => {
|
||||
it('should return null for unknown command', () => {
|
||||
expect(registry.lookup('UNKNOWN')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for empty string', () => {
|
||||
expect(registry.lookup('')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for random text', () => {
|
||||
expect(registry.lookup('XYZABC')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getToolId', () => {
|
||||
it('should return toolId for LINE', () => {
|
||||
expect(registry.getToolId('LINE')).toBe('line');
|
||||
});
|
||||
|
||||
it('should return toolId for CIRCLE alias C', () => {
|
||||
expect(registry.getToolId('C')).toBe('circle');
|
||||
});
|
||||
|
||||
it('should return null for meta commands like UNDO', () => {
|
||||
expect(registry.getToolId('UNDO')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for unknown command', () => {
|
||||
expect(registry.getToolId('UNKNOWN')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLabel', () => {
|
||||
it('should return label for LINE', () => {
|
||||
const label = registry.getLabel('LINE');
|
||||
expect(label).not.toBeNull();
|
||||
expect(label).toContain('Linie');
|
||||
});
|
||||
|
||||
it('should return label for alias L', () => {
|
||||
const label = registry.getLabel('L');
|
||||
expect(label).not.toBeNull();
|
||||
expect(label).toContain('Linie');
|
||||
});
|
||||
|
||||
it('should return null for unknown command', () => {
|
||||
expect(registry.getLabel('UNKNOWN')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllCommands', () => {
|
||||
it('should return all command definitions', () => {
|
||||
const all = registry.getAllCommands();
|
||||
expect(all.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it('should include LINE, CIRCLE, RECT, MOVE, UNDO', () => {
|
||||
const all = registry.getAllCommands();
|
||||
const names = all.map(c => c.name);
|
||||
expect(names).toContain('LINE');
|
||||
expect(names).toContain('CIRCLE');
|
||||
expect(names).toContain('RECT');
|
||||
expect(names).toContain('MOVE');
|
||||
expect(names).toContain('UNDO');
|
||||
});
|
||||
});
|
||||
|
||||
describe('autocomplete', () => {
|
||||
it('should return exact match first', () => {
|
||||
const results = registry.autocomplete('L');
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
// Exact alias match 'L' should be LINE (priority 1)
|
||||
expect(results[0].name).toBe('LINE');
|
||||
});
|
||||
|
||||
it('should return commands starting with input', () => {
|
||||
const results = registry.autocomplete('LI');
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
const names = results.map(c => c.name);
|
||||
expect(names).toContain('LINE');
|
||||
});
|
||||
|
||||
it('should return empty for empty input', () => {
|
||||
expect(registry.autocomplete('')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return max 10 results', () => {
|
||||
const results = registry.autocomplete('A');
|
||||
expect(results.length).toBeLessThanOrEqual(10);
|
||||
});
|
||||
|
||||
it('should match aliases', () => {
|
||||
const results = registry.autocomplete('PL');
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
const names = results.map(c => c.name);
|
||||
expect(names).toContain('POLYLINE');
|
||||
});
|
||||
|
||||
it('should match case-insensitively', () => {
|
||||
const results = registry.autocomplete('line');
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].name).toBe('LINE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllNames', () => {
|
||||
it('should return all names and aliases in uppercase', () => {
|
||||
const names = registry.getAllNames();
|
||||
expect(names.length).toBeGreaterThan(10);
|
||||
expect(names.every(n => n === n.toUpperCase())).toBe(true);
|
||||
});
|
||||
|
||||
it('should include LINE and alias L', () => {
|
||||
const names = registry.getAllNames();
|
||||
expect(names).toContain('LINE');
|
||||
expect(names).toContain('L');
|
||||
});
|
||||
});
|
||||
|
||||
describe('categories', () => {
|
||||
it('should have draw category commands', () => {
|
||||
const all = registry.getAllCommands();
|
||||
const draw = all.filter(c => c.category === 'draw');
|
||||
expect(draw.length).toBeGreaterThan(5);
|
||||
const names = draw.map(c => c.name);
|
||||
expect(names).toContain('LINE');
|
||||
expect(names).toContain('CIRCLE');
|
||||
});
|
||||
|
||||
it('should have modify category commands', () => {
|
||||
const all = registry.getAllCommands();
|
||||
const modify = all.filter(c => c.category === 'modify');
|
||||
expect(modify.length).toBeGreaterThan(5);
|
||||
const names = modify.map(c => c.name);
|
||||
expect(names).toContain('MOVE');
|
||||
expect(names).toContain('ROTATE');
|
||||
});
|
||||
|
||||
it('should have view category commands', () => {
|
||||
const all = registry.getAllCommands();
|
||||
const view = all.filter(c => c.category === 'view');
|
||||
expect(view.length).toBeGreaterThan(0);
|
||||
const names = view.map(c => c.name);
|
||||
expect(names).toContain('PAN');
|
||||
expect(names).toContain('ZOOM');
|
||||
});
|
||||
|
||||
it('should have meta category commands', () => {
|
||||
const all = registry.getAllCommands();
|
||||
const meta = all.filter(c => c.category === 'meta');
|
||||
expect(meta.length).toBeGreaterThan(0);
|
||||
const names = meta.map(c => c.name);
|
||||
expect(names).toContain('UNDO');
|
||||
expect(names).toContain('REDO');
|
||||
});
|
||||
|
||||
it('should have special category commands', () => {
|
||||
const all = registry.getAllCommands();
|
||||
const special = all.filter(c => c.category === 'special');
|
||||
expect(special.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* geometry.ts Tests – Pure transformation functions (move, rotate, scale, mirror)
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
moveElement,
|
||||
rotateElement,
|
||||
scaleElement,
|
||||
mirrorElement,
|
||||
offsetElement,
|
||||
getElementBBox,
|
||||
distance,
|
||||
angleBetween,
|
||||
} from '../src/tools/modification/geometry';
|
||||
import type { CADElement } from '../src/types/cad.types';
|
||||
|
||||
function makeLine(id: string, x1: number, y1: number, x2: number, y2: number): CADElement {
|
||||
return {
|
||||
id,
|
||||
type: 'line',
|
||||
layerId: 'layer-1',
|
||||
x: (x1 + x2) / 2,
|
||||
y: (y1 + y2) / 2,
|
||||
width: Math.abs(x2 - x1),
|
||||
height: Math.abs(y2 - y1),
|
||||
properties: { x1, y1, x2, y2 },
|
||||
};
|
||||
}
|
||||
|
||||
function makeRect(id: string, cx: number, cy: number, w: number, h: number): CADElement {
|
||||
return {
|
||||
id,
|
||||
type: 'rect',
|
||||
layerId: 'layer-1',
|
||||
x: cx,
|
||||
y: cy,
|
||||
width: w,
|
||||
height: h,
|
||||
properties: {},
|
||||
};
|
||||
}
|
||||
|
||||
function makeCircle(id: string, cx: number, cy: number, r: number): CADElement {
|
||||
return {
|
||||
id,
|
||||
type: 'circle',
|
||||
layerId: 'layer-1',
|
||||
x: cx,
|
||||
y: cy,
|
||||
width: r * 2,
|
||||
height: r * 2,
|
||||
properties: { radius: r },
|
||||
};
|
||||
}
|
||||
|
||||
function makePolyline(id: string, points: Array<{x:number;y:number}>): CADElement {
|
||||
const xs = points.map(p => p.x);
|
||||
const ys = points.map(p => p.y);
|
||||
return {
|
||||
id,
|
||||
type: 'polyline',
|
||||
layerId: 'layer-1',
|
||||
x: (Math.min(...xs) + Math.max(...xs)) / 2,
|
||||
y: (Math.min(...ys) + Math.max(...ys)) / 2,
|
||||
width: Math.max(...xs) - Math.min(...xs),
|
||||
height: Math.max(...ys) - Math.min(...ys),
|
||||
properties: { points },
|
||||
};
|
||||
}
|
||||
|
||||
describe('geometry – moveElement', () => {
|
||||
it('should shift x and y by dx, dy', () => {
|
||||
const el = makeRect('r1', 100, 100, 40, 40);
|
||||
const moved = moveElement(el, 50, 30);
|
||||
expect(moved.x).toBe(150);
|
||||
expect(moved.y).toBe(130);
|
||||
});
|
||||
|
||||
it('should shift line endpoint properties', () => {
|
||||
const el = makeLine('l1', 0, 0, 100, 0);
|
||||
const moved = moveElement(el, 50, 30);
|
||||
expect(moved.properties.x1).toBe(50);
|
||||
expect(moved.properties.y1).toBe(30);
|
||||
expect(moved.properties.x2).toBe(150);
|
||||
expect(moved.properties.y2).toBe(30);
|
||||
});
|
||||
|
||||
it('should shift polyline points', () => {
|
||||
const el = makePolyline('p1', [{ x: 0, y: 0 }, { x: 100, y: 50 }]);
|
||||
const moved = moveElement(el, 10, 20);
|
||||
expect(moved.properties.points![0].x).toBe(10);
|
||||
expect(moved.properties.points![0].y).toBe(20);
|
||||
expect(moved.properties.points![1].x).toBe(110);
|
||||
expect(moved.properties.points![1].y).toBe(70);
|
||||
});
|
||||
|
||||
it('should not modify the original element (immutability)', () => {
|
||||
const el = makeLine('l1', 0, 0, 100, 0);
|
||||
const original = JSON.parse(JSON.stringify(el));
|
||||
moveElement(el, 50, 50);
|
||||
expect(el).toEqual(original);
|
||||
});
|
||||
});
|
||||
|
||||
describe('geometry – rotateElement', () => {
|
||||
it('should rotate element position around center', () => {
|
||||
const el = makeRect('r1', 100, 0, 40, 40);
|
||||
const rotated = rotateElement(el, 0, 0, 90);
|
||||
expect(rotated.x).toBeCloseTo(0, 0);
|
||||
expect(rotated.y).toBeCloseTo(100, 0);
|
||||
});
|
||||
|
||||
it('should rotate line endpoints around center', () => {
|
||||
const el = makeLine('l1', 100, 0, 200, 0);
|
||||
const rotated = rotateElement(el, 0, 0, 90);
|
||||
expect(rotated.properties.x1).toBeCloseTo(0, 0);
|
||||
expect(rotated.properties.y1).toBeCloseTo(100, 0);
|
||||
expect(rotated.properties.x2).toBeCloseTo(0, 0);
|
||||
expect(rotated.properties.y2).toBeCloseTo(200, 0);
|
||||
});
|
||||
|
||||
it('should rotate 180 degrees correctly', () => {
|
||||
const el = makeRect('r1', 100, 0, 40, 40);
|
||||
const rotated = rotateElement(el, 0, 0, 180);
|
||||
expect(rotated.x).toBeCloseTo(-100, 0);
|
||||
expect(rotated.y).toBeCloseTo(0, 0);
|
||||
});
|
||||
|
||||
it('should rotate 360 degrees back to original position', () => {
|
||||
const el = makeRect('r1', 100, 0, 40, 40);
|
||||
const rotated = rotateElement(el, 0, 0, 360);
|
||||
expect(rotated.x).toBeCloseTo(100, 5);
|
||||
expect(rotated.y).toBeCloseTo(0, 5);
|
||||
});
|
||||
|
||||
it('should swap width/height for 90-degree rotation on rect', () => {
|
||||
const el = makeRect('r1', 100, 100, 80, 40);
|
||||
const rotated = rotateElement(el, 100, 100, 90);
|
||||
expect(rotated.width).toBe(40);
|
||||
expect(rotated.height).toBe(80);
|
||||
});
|
||||
|
||||
it('should add rotation to properties.rotation', () => {
|
||||
const el = makeRect('r1', 100, 100, 40, 40);
|
||||
el.properties.rotation = 30;
|
||||
const rotated = rotateElement(el, 100, 100, 45);
|
||||
expect(rotated.properties.rotation).toBe(75);
|
||||
});
|
||||
|
||||
it('should not modify the original element', () => {
|
||||
const el = makeLine('l1', 100, 0, 200, 0);
|
||||
const original = JSON.parse(JSON.stringify(el));
|
||||
rotateElement(el, 0, 0, 90);
|
||||
expect(el).toEqual(original);
|
||||
});
|
||||
});
|
||||
|
||||
describe('geometry – scaleElement', () => {
|
||||
it('should scale element position and dimensions', () => {
|
||||
const el = makeRect('r1', 100, 100, 40, 40);
|
||||
const scaled = scaleElement(el, 100, 100, 2, 2);
|
||||
expect(scaled.x).toBe(100);
|
||||
expect(scaled.y).toBe(100);
|
||||
expect(scaled.width).toBe(80);
|
||||
expect(scaled.height).toBe(80);
|
||||
});
|
||||
|
||||
it('should scale element away from center', () => {
|
||||
const el = makeRect('r1', 100, 0, 40, 40);
|
||||
const scaled = scaleElement(el, 0, 0, 2, 2);
|
||||
expect(scaled.x).toBe(200);
|
||||
expect(scaled.y).toBe(0);
|
||||
expect(scaled.width).toBe(80);
|
||||
expect(scaled.height).toBe(80);
|
||||
});
|
||||
|
||||
it('should scale line endpoints', () => {
|
||||
const el = makeLine('l1', 100, 0, 200, 0);
|
||||
const scaled = scaleElement(el, 0, 0, 2, 2);
|
||||
expect(scaled.properties.x1).toBe(200);
|
||||
expect(scaled.properties.x2).toBe(400);
|
||||
});
|
||||
|
||||
it('should scale circle radius', () => {
|
||||
const el = makeCircle('c1', 100, 100, 40);
|
||||
const scaled = scaleElement(el, 100, 100, 2, 2);
|
||||
expect(scaled.properties.radius).toBe(80);
|
||||
});
|
||||
|
||||
it('should not modify the original element', () => {
|
||||
const el = makeRect('r1', 100, 100, 40, 40);
|
||||
const original = JSON.parse(JSON.stringify(el));
|
||||
scaleElement(el, 100, 100, 2, 2);
|
||||
expect(el).toEqual(original);
|
||||
});
|
||||
});
|
||||
|
||||
describe('geometry – mirrorElement', () => {
|
||||
it('should mirror across a vertical line', () => {
|
||||
const el = makeRect('r1', 100, 0, 40, 40);
|
||||
const mirrored = mirrorElement(el, 200, 0, 200, 100);
|
||||
expect(mirrored.x).toBe(300);
|
||||
expect(mirrored.y).toBe(0);
|
||||
});
|
||||
|
||||
it('should mirror across a horizontal line', () => {
|
||||
const el = makeRect('r1', 0, 100, 40, 40);
|
||||
const mirrored = mirrorElement(el, 0, 200, 100, 200);
|
||||
expect(mirrored.x).toBe(0);
|
||||
expect(mirrored.y).toBe(300);
|
||||
});
|
||||
|
||||
it('should mirror line endpoints', () => {
|
||||
const el = makeLine('l1', 0, 0, 100, 0);
|
||||
const mirrored = mirrorElement(el, 50, 0, 50, 100);
|
||||
expect(mirrored.properties.x1).toBe(100);
|
||||
expect(mirrored.properties.x2).toBe(0);
|
||||
});
|
||||
|
||||
it('should return element unchanged for zero-length mirror axis', () => {
|
||||
const el = makeRect('r1', 100, 100, 40, 40);
|
||||
const mirrored = mirrorElement(el, 50, 50, 50, 50);
|
||||
expect(mirrored.x).toBe(100);
|
||||
expect(mirrored.y).toBe(100);
|
||||
});
|
||||
|
||||
it('should not modify the original element', () => {
|
||||
const el = makeLine('l1', 0, 0, 100, 0);
|
||||
const original = JSON.parse(JSON.stringify(el));
|
||||
mirrorElement(el, 50, 0, 50, 100);
|
||||
expect(el).toEqual(original);
|
||||
});
|
||||
});
|
||||
|
||||
describe('geometry – offsetElement', () => {
|
||||
it('should offset a line perpendicularly', () => {
|
||||
const el = makeLine('l1', 0, 0, 100, 0);
|
||||
const offset = offsetElement(el, 10);
|
||||
expect(offset.properties.y1).toBe(10);
|
||||
expect(offset.properties.y2).toBe(10);
|
||||
});
|
||||
|
||||
it('should offset a circle radius', () => {
|
||||
const el = makeCircle('c1', 100, 100, 40);
|
||||
const offset = offsetElement(el, 10);
|
||||
expect(offset.properties.radius).toBe(50);
|
||||
});
|
||||
|
||||
it('should return element for unknown type', () => {
|
||||
const el = makeRect('r1', 100, 100, 40, 40);
|
||||
const offset = offsetElement(el, 10);
|
||||
expect(offset.x).toBe(100);
|
||||
expect(offset.y).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('geometry – getElementBBox', () => {
|
||||
it('should return bbox for rect element', () => {
|
||||
const el = makeRect('r1', 100, 100, 40, 60);
|
||||
const bb = getElementBBox(el);
|
||||
expect(bb.minX).toBe(80);
|
||||
expect(bb.minY).toBe(70);
|
||||
expect(bb.maxX).toBe(120);
|
||||
expect(bb.maxY).toBe(130);
|
||||
});
|
||||
|
||||
it('should return bbox for polyline element from points', () => {
|
||||
const el = makePolyline('p1', [{ x: 10, y: 20 }, { x: 100, y: 80 }]);
|
||||
const bb = getElementBBox(el);
|
||||
expect(bb.minX).toBe(10);
|
||||
expect(bb.minY).toBe(20);
|
||||
expect(bb.maxX).toBe(100);
|
||||
expect(bb.maxY).toBe(80);
|
||||
});
|
||||
});
|
||||
|
||||
describe('geometry – distance', () => {
|
||||
it('should calculate distance between two points', () => {
|
||||
expect(distance({ x: 0, y: 0 }, { x: 3, y: 4 })).toBe(5);
|
||||
});
|
||||
|
||||
it('should return 0 for same point', () => {
|
||||
expect(distance({ x: 5, y: 5 }, { x: 5, y: 5 })).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('geometry – angleBetween', () => {
|
||||
it('should calculate angle between two points in degrees', () => {
|
||||
expect(angleBetween({ x: 0, y: 0 }, { x: 100, y: 0 })).toBe(0);
|
||||
});
|
||||
|
||||
it('should calculate 90-degree angle', () => {
|
||||
expect(angleBetween({ x: 0, y: 0 }, { x: 0, y: 100 })).toBe(90);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
// Mock Canvas 2D context for jsdom (jsdom doesn't implement CanvasRenderingContext2D)
|
||||
const noop = () => {};
|
||||
const mockCtx = {
|
||||
fillRect: noop,
|
||||
strokeRect: noop,
|
||||
clearRect: noop,
|
||||
beginPath: noop,
|
||||
closePath: noop,
|
||||
moveTo: noop,
|
||||
lineTo: noop,
|
||||
arc: noop,
|
||||
arcTo: noop,
|
||||
rect: noop,
|
||||
ellipse: noop,
|
||||
quadraticCurveTo: noop,
|
||||
bezierCurveTo: noop,
|
||||
fill: noop,
|
||||
stroke: noop,
|
||||
save: noop,
|
||||
restore: noop,
|
||||
scale: noop,
|
||||
translate: noop,
|
||||
rotate: noop,
|
||||
transform: noop,
|
||||
setTransform: noop,
|
||||
resetTransform: noop,
|
||||
setLineDash: noop,
|
||||
getLineDash: () => [] as number[],
|
||||
clip: noop,
|
||||
fillText: noop,
|
||||
strokeText: noop,
|
||||
measureText: () => ({ width: 0 }) as TextMetrics,
|
||||
drawImage: noop,
|
||||
createImageData: () => ({ width: 0, height: 0, data: new Uint8ClampedArray(0) }) as ImageData,
|
||||
getImageData: () => ({ width: 0, height: 0, data: new Uint8ClampedArray(0) }) as ImageData,
|
||||
putImageData: noop,
|
||||
createLinearGradient: () => ({ addColorStop: noop }) as CanvasGradient,
|
||||
createRadialGradient: () => ({ addColorStop: noop }) as CanvasGradient,
|
||||
createPattern: () => null as unknown as CanvasPattern,
|
||||
isPointInPath: () => false,
|
||||
isPointInStroke: () => false,
|
||||
// Properties
|
||||
canvas: null as unknown as HTMLCanvasElement,
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
lineWidth: 1,
|
||||
lineCap: 'butt' as CanvasLineCap,
|
||||
lineJoin: 'miter' as CanvasLineJoin,
|
||||
miterLimit: 10,
|
||||
lineDashOffset: 0,
|
||||
font: '10px sans-serif',
|
||||
textAlign: 'start' as CanvasTextAlign,
|
||||
textBaseline: 'alphabetic' as CanvasTextBaseline,
|
||||
direction: 'ltr' as CanvasTextDirection,
|
||||
globalAlpha: 1,
|
||||
globalCompositeOperation: 'source-over' as GlobalCompositeOperation,
|
||||
imageSmoothingEnabled: true,
|
||||
imageSmoothingQuality: 'low' as ImageSmoothingQuality,
|
||||
shadowBlur: 0,
|
||||
shadowColor: 'rgba(0, 0, 0, 0)',
|
||||
shadowOffsetX: 0,
|
||||
shadowOffsetY: 0,
|
||||
filter: 'none',
|
||||
};
|
||||
|
||||
// Override getContext to return our mock for '2d'
|
||||
HTMLCanvasElement.prototype.getContext = function (contextId: string) {
|
||||
if (contextId === '2d') {
|
||||
return mockCtx as unknown as CanvasRenderingContext2D;
|
||||
}
|
||||
return null;
|
||||
} as typeof HTMLCanvasElement.prototype.getContext;
|
||||
|
||||
// Mock getBoundingClientRect for canvas elements
|
||||
const origGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
|
||||
HTMLElement.prototype.getBoundingClientRect = function () {
|
||||
if (this instanceof HTMLCanvasElement) {
|
||||
return {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: this.width,
|
||||
bottom: this.height,
|
||||
width: this.width,
|
||||
height: this.height,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect;
|
||||
}
|
||||
return origGetBoundingClientRect.call(this);
|
||||
};
|
||||
Reference in New Issue
Block a user