Add test suite with 132 unit tests across all modules
Covers DesignState (40 tests), HouseRenderer (19), InteractionManager (24), ThemeManager (8), ExportManager (11), and CatalogPanel (30). Uses vitest with THREE.js mocks for browser-free testing.
This commit is contained in:
260
tests/renderer.test.js
Normal file
260
tests/renderer.test.js
Normal file
@@ -0,0 +1,260 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { HouseRenderer, COLORS } from '../src/renderer.js';
|
||||
|
||||
// Mock container with minimal DOM-like interface
|
||||
function makeContainer() {
|
||||
const listeners = {};
|
||||
return {
|
||||
clientWidth: 800,
|
||||
clientHeight: 600,
|
||||
appendChild: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
addEventListener: (type, fn) => {
|
||||
listeners[type] = listeners[type] || [];
|
||||
listeners[type].push(fn);
|
||||
},
|
||||
removeEventListener: vi.fn(),
|
||||
_listeners: listeners,
|
||||
_fireEvent: (type, detail) => {
|
||||
for (const fn of listeners[type] || []) fn({ detail });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe('HouseRenderer', () => {
|
||||
let renderer;
|
||||
|
||||
beforeEach(() => {
|
||||
// Stub global APIs used by the constructor
|
||||
globalThis.window = globalThis.window || {};
|
||||
globalThis.window.addEventListener = vi.fn();
|
||||
globalThis.window.devicePixelRatio = 1;
|
||||
globalThis.requestAnimationFrame = vi.fn();
|
||||
|
||||
// Stub document.createElement for canvas room labels
|
||||
globalThis.document = globalThis.document || {};
|
||||
globalThis.document.createElement = (tag) => {
|
||||
if (tag === 'canvas') {
|
||||
return {
|
||||
getContext: () => ({
|
||||
font: '',
|
||||
measureText: () => ({ width: 100 }),
|
||||
fillStyle: '',
|
||||
fillRect: () => {},
|
||||
textBaseline: '',
|
||||
textAlign: '',
|
||||
fillText: () => {}
|
||||
}),
|
||||
width: 0,
|
||||
height: 0
|
||||
};
|
||||
}
|
||||
return { addEventListener: vi.fn(), click: vi.fn(), type: '', accept: '', files: [] };
|
||||
};
|
||||
|
||||
renderer = new HouseRenderer(makeContainer());
|
||||
});
|
||||
|
||||
describe('COLORS export', () => {
|
||||
it('exports expected color structure', () => {
|
||||
expect(COLORS.wall).toHaveProperty('exterior');
|
||||
expect(COLORS.wall).toHaveProperty('interior');
|
||||
expect(COLORS.floor).toHaveProperty('tile');
|
||||
expect(COLORS.floor).toHaveProperty('hardwood');
|
||||
expect(COLORS).toHaveProperty('ceiling');
|
||||
expect(COLORS).toHaveProperty('door');
|
||||
expect(COLORS).toHaveProperty('window');
|
||||
expect(COLORS).toHaveProperty('windowFrame');
|
||||
expect(COLORS).toHaveProperty('grid');
|
||||
expect(COLORS).toHaveProperty('selected');
|
||||
});
|
||||
});
|
||||
|
||||
describe('initial state', () => {
|
||||
it('has null data references', () => {
|
||||
expect(renderer.houseData).toBeNull();
|
||||
expect(renderer.catalogData).toBeNull();
|
||||
expect(renderer.designData).toBeNull();
|
||||
});
|
||||
|
||||
it('starts on floor 0', () => {
|
||||
expect(renderer.currentFloor).toBe(0);
|
||||
});
|
||||
|
||||
it('has empty mesh maps', () => {
|
||||
expect(renderer.roomMeshes.size).toBe(0);
|
||||
expect(renderer.furnitureMeshes.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('_computeWallSegments', () => {
|
||||
it('returns single full-height segment with no openings', () => {
|
||||
const segments = renderer._computeWallSegments([], 4, 2.6);
|
||||
expect(segments).toHaveLength(1);
|
||||
expect(segments[0]).toEqual({ w: 4, h: 2.6, cx: 2, cy: 1.3 });
|
||||
});
|
||||
|
||||
it('creates segments around a door opening', () => {
|
||||
const openings = [{ position: 1, width: 1, height: 2.1, bottom: 0 }];
|
||||
const segments = renderer._computeWallSegments(openings, 4, 2.6);
|
||||
|
||||
// Left of door
|
||||
const left = segments.find(s => s.cx < 1);
|
||||
expect(left).toBeDefined();
|
||||
expect(left.w).toBeCloseTo(1, 1);
|
||||
expect(left.h).toBe(2.6);
|
||||
|
||||
// Above door
|
||||
const above = segments.find(s => s.cy > 2.1);
|
||||
expect(above).toBeDefined();
|
||||
expect(above.w).toBe(1); // door width
|
||||
expect(above.h).toBeCloseTo(0.5, 1);
|
||||
|
||||
// Right of door
|
||||
const right = segments.find(s => s.cx > 2);
|
||||
expect(right).toBeDefined();
|
||||
expect(right.w).toBeCloseTo(2, 1);
|
||||
});
|
||||
|
||||
it('creates segments around a window with sill', () => {
|
||||
const openings = [{
|
||||
position: 1, width: 1.2, height: 1.0,
|
||||
bottom: 0.8, sillHeight: 0.8
|
||||
}];
|
||||
const segments = renderer._computeWallSegments(openings, 4, 2.6);
|
||||
|
||||
// Should have: left, above, below (sill), right
|
||||
expect(segments.length).toBeGreaterThanOrEqual(4);
|
||||
|
||||
// Below-sill segment
|
||||
const below = segments.find(s => s.h === 0.8 && s.w === 1.2);
|
||||
expect(below).toBeDefined();
|
||||
});
|
||||
|
||||
it('handles multiple openings', () => {
|
||||
const openings = [
|
||||
{ position: 0.5, width: 0.8, height: 2.1, bottom: 0 },
|
||||
{ position: 2.5, width: 1.0, height: 1.0, bottom: 0.8 }
|
||||
];
|
||||
const segments = renderer._computeWallSegments(openings, 5, 2.6);
|
||||
// Should have segments between and around both openings
|
||||
expect(segments.length).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFloors / getRooms', () => {
|
||||
it('getFloors returns empty array with no house data', () => {
|
||||
expect(renderer.getFloors()).toEqual([]);
|
||||
});
|
||||
|
||||
it('getRooms returns empty array with no house data', () => {
|
||||
expect(renderer.getRooms()).toEqual([]);
|
||||
});
|
||||
|
||||
it('getFloors returns floor list after loading', () => {
|
||||
renderer.houseData = {
|
||||
floors: [
|
||||
{ id: 'eg', name: 'Erdgeschoss', nameEN: 'Ground Floor', rooms: [] },
|
||||
{ id: 'og', name: 'Obergeschoss', nameEN: 'Upper Floor', rooms: [] }
|
||||
]
|
||||
};
|
||||
const floors = renderer.getFloors();
|
||||
expect(floors).toHaveLength(2);
|
||||
expect(floors[0]).toEqual({ index: 0, id: 'eg', name: 'Erdgeschoss', nameEN: 'Ground Floor' });
|
||||
});
|
||||
|
||||
it('getRooms returns rooms for current floor', () => {
|
||||
renderer.houseData = {
|
||||
floors: [{
|
||||
id: 'eg', name: 'EG', rooms: [
|
||||
{ id: 'r1', name: 'Room1', nameEN: 'R1', type: 'living', dimensions: { width: 4, length: 5 } },
|
||||
{ id: 'r2', name: 'Room2', nameEN: 'R2', type: 'bedroom', dimensions: { width: 3, length: 3 } }
|
||||
]
|
||||
}]
|
||||
};
|
||||
renderer.currentFloor = 0;
|
||||
const rooms = renderer.getRooms();
|
||||
expect(rooms).toHaveLength(2);
|
||||
expect(rooms[0].id).toBe('r1');
|
||||
expect(rooms[0].area).toBe(20);
|
||||
expect(rooms[1].area).toBe(9);
|
||||
});
|
||||
|
||||
it('getRooms handles invalid floor index', () => {
|
||||
renderer.houseData = { floors: [] };
|
||||
renderer.currentFloor = 5;
|
||||
expect(renderer.getRooms()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setControlsEnabled', () => {
|
||||
it('toggles controls.enabled', () => {
|
||||
renderer.setControlsEnabled(false);
|
||||
expect(renderer.controls.enabled).toBe(false);
|
||||
renderer.setControlsEnabled(true);
|
||||
expect(renderer.controls.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('showFloor', () => {
|
||||
it('updates currentFloor', () => {
|
||||
renderer.houseData = {
|
||||
floors: [
|
||||
{ id: 'eg', name: 'EG', ceilingHeight: 2.6, rooms: [] },
|
||||
{ id: 'og', name: 'OG', ceilingHeight: 2.5, rooms: [] }
|
||||
]
|
||||
};
|
||||
renderer.showFloor(1);
|
||||
expect(renderer.currentFloor).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('_buildFurnitureMesh', () => {
|
||||
it('returns group for catalog item with box parts', () => {
|
||||
const item = {
|
||||
id: 'test',
|
||||
mesh: {
|
||||
type: 'group',
|
||||
parts: [
|
||||
{ geometry: 'box', size: [1, 0.5, 0.5], position: [0, 0.25, 0], color: '#8b4513' }
|
||||
]
|
||||
}
|
||||
};
|
||||
const group = renderer._buildFurnitureMesh(item);
|
||||
expect(group.children).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns group for cylinder parts', () => {
|
||||
const item = {
|
||||
id: 'test',
|
||||
mesh: {
|
||||
type: 'group',
|
||||
parts: [
|
||||
{ geometry: 'cylinder', radius: 0.1, height: 0.7, position: [0, 0.35, 0], color: '#333' }
|
||||
]
|
||||
}
|
||||
};
|
||||
const group = renderer._buildFurnitureMesh(item);
|
||||
expect(group.children).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns empty group for missing mesh def', () => {
|
||||
const group = renderer._buildFurnitureMesh({ id: 'no-mesh' });
|
||||
expect(group.children).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('skips unknown geometry types', () => {
|
||||
const item = {
|
||||
id: 'test',
|
||||
mesh: {
|
||||
type: 'group',
|
||||
parts: [
|
||||
{ geometry: 'sphere', radius: 0.5, position: [0, 0, 0], color: '#fff' }
|
||||
]
|
||||
}
|
||||
};
|
||||
const group = renderer._buildFurnitureMesh(item);
|
||||
expect(group.children).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user