353 lines
11 KiB
TypeScript
353 lines
11 KiB
TypeScript
|
|
/**
|
|||
|
|
* 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();
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
});
|