Files
web-cad/frontend/tests/RenderEngine.test.ts
T
Leopoldadmin 1bcfaffdd4 fix: replace hardcoded localhost:3001 with relative API URL + add canvas mock and integration test
- Fix frontend Network error: API_BASE now defaults to empty string (same-origin)
- Fix 3 files: api.ts, AuthContext.tsx, Dashboard.tsx
- Add Canvas 2D context mock in tests/setup.ts for jsdom
- Fix -0 vs +0 in ZoomPanController.getViewport()
- Add IntegrationWorkflow.test.ts (35 tests, full CAD workflow)
2026-06-26 17:48:24 +02:00

353 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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();
});
});
});