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:
311
tests/interaction.test.js
Normal file
311
tests/interaction.test.js
Normal file
@@ -0,0 +1,311 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { InteractionManager } from '../src/interaction.js';
|
||||
import { DesignState } from '../src/state.js';
|
||||
import { Group, Vector3, Euler } from '../tests/__mocks__/three.js';
|
||||
|
||||
function makeState() {
|
||||
return new DesignState({
|
||||
name: 'Test',
|
||||
rooms: [{
|
||||
roomId: 'room-1',
|
||||
furniture: [
|
||||
{ catalogId: 'chair', position: { x: 1, z: 2 }, rotation: 0 },
|
||||
{ catalogId: 'table', position: { x: 3, z: 4 }, rotation: 90 }
|
||||
]
|
||||
}]
|
||||
});
|
||||
}
|
||||
|
||||
function makeMockRenderer() {
|
||||
const listeners = {};
|
||||
const canvasListeners = {};
|
||||
const container = {
|
||||
addEventListener: (t, fn) => { listeners[t] = listeners[t] || []; listeners[t].push(fn); },
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn()
|
||||
};
|
||||
|
||||
const furnitureMeshes = new Map();
|
||||
|
||||
return {
|
||||
container,
|
||||
renderer: {
|
||||
domElement: {
|
||||
addEventListener: (t, fn) => { canvasListeners[t] = canvasListeners[t] || []; canvasListeners[t].push(fn); },
|
||||
removeEventListener: vi.fn(),
|
||||
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 })
|
||||
}
|
||||
},
|
||||
camera: { position: new Vector3(6, 12, 14) },
|
||||
raycaster: {
|
||||
setFromCamera: vi.fn(),
|
||||
intersectObject: vi.fn(() => []),
|
||||
ray: { intersectPlane: vi.fn(() => new Vector3()) }
|
||||
},
|
||||
houseData: {
|
||||
floors: [{
|
||||
id: 'eg',
|
||||
rooms: [{
|
||||
id: 'room-1',
|
||||
position: { x: 0, y: 0 },
|
||||
dimensions: { width: 5, length: 5 }
|
||||
}]
|
||||
}]
|
||||
},
|
||||
currentFloor: 0,
|
||||
furnitureMeshes,
|
||||
setControlsEnabled: vi.fn(),
|
||||
designData: {},
|
||||
_clearFloor: vi.fn(),
|
||||
_renderRoom: vi.fn(),
|
||||
_placeFurnitureForFloor: vi.fn(),
|
||||
_listeners: listeners,
|
||||
_canvasListeners: canvasListeners
|
||||
};
|
||||
}
|
||||
|
||||
describe('InteractionManager', () => {
|
||||
let interaction, state, mockRenderer;
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.window = globalThis.window || {};
|
||||
globalThis.window.addEventListener = vi.fn();
|
||||
globalThis.window.removeEventListener = vi.fn();
|
||||
|
||||
state = makeState();
|
||||
mockRenderer = makeMockRenderer();
|
||||
interaction = new InteractionManager(mockRenderer, state);
|
||||
});
|
||||
|
||||
describe('mode system', () => {
|
||||
it('starts in view mode', () => {
|
||||
expect(interaction.mode).toBe('view');
|
||||
});
|
||||
|
||||
it('setMode changes mode and emits event', () => {
|
||||
const listener = vi.fn();
|
||||
interaction.onChange(listener);
|
||||
interaction.setMode('select');
|
||||
expect(interaction.mode).toBe('select');
|
||||
expect(listener).toHaveBeenCalledWith('modechange', { oldMode: 'view', newMode: 'select' });
|
||||
});
|
||||
|
||||
it('setMode does nothing if already in that mode', () => {
|
||||
const listener = vi.fn();
|
||||
interaction.onChange(listener);
|
||||
interaction.setMode('view');
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('switching to view clears selection', () => {
|
||||
// Use select() to properly set up, which also transitions to 'select' mode
|
||||
const mesh = new Group();
|
||||
mesh.userData = { roomId: 'room-1', furnitureIndex: 0, catalogId: 'chair', itemName: 'Chair' };
|
||||
interaction.select(mesh);
|
||||
expect(interaction.mode).toBe('select');
|
||||
|
||||
interaction.setMode('view');
|
||||
expect(interaction.selectedObject).toBeNull();
|
||||
expect(interaction.selectedRoomId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('selection', () => {
|
||||
it('select sets selection properties', () => {
|
||||
const mesh = new Group();
|
||||
mesh.userData = { roomId: 'room-1', furnitureIndex: 0, catalogId: 'chair', itemName: 'Chair' };
|
||||
|
||||
interaction.select(mesh);
|
||||
expect(interaction.selectedObject).toBe(mesh);
|
||||
expect(interaction.selectedRoomId).toBe('room-1');
|
||||
expect(interaction.selectedIndex).toBe(0);
|
||||
});
|
||||
|
||||
it('select emits select event', () => {
|
||||
const listener = vi.fn();
|
||||
interaction.onChange(listener);
|
||||
|
||||
const mesh = new Group();
|
||||
mesh.userData = { roomId: 'room-1', furnitureIndex: 0, catalogId: 'chair', itemName: 'Chair' };
|
||||
interaction.select(mesh);
|
||||
|
||||
expect(listener).toHaveBeenCalledWith('select', expect.objectContaining({
|
||||
roomId: 'room-1',
|
||||
index: 0,
|
||||
catalogId: 'chair'
|
||||
}));
|
||||
});
|
||||
|
||||
it('select switches mode from view to select', () => {
|
||||
const mesh = new Group();
|
||||
mesh.userData = { roomId: 'room-1', furnitureIndex: 0, catalogId: 'chair', itemName: 'Chair' };
|
||||
interaction.select(mesh);
|
||||
expect(interaction.mode).toBe('select');
|
||||
});
|
||||
|
||||
it('selecting same object does nothing', () => {
|
||||
const mesh = new Group();
|
||||
mesh.userData = { roomId: 'room-1', furnitureIndex: 0, catalogId: 'chair', itemName: 'Chair' };
|
||||
interaction.select(mesh);
|
||||
const listener = vi.fn();
|
||||
interaction.onChange(listener);
|
||||
interaction.select(mesh); // same mesh
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clearSelection resets state and emits deselect', () => {
|
||||
const mesh = new Group();
|
||||
mesh.userData = { roomId: 'room-1', furnitureIndex: 0, catalogId: 'chair', itemName: 'Chair' };
|
||||
interaction.select(mesh);
|
||||
|
||||
const listener = vi.fn();
|
||||
interaction.onChange(listener);
|
||||
interaction.clearSelection();
|
||||
|
||||
expect(interaction.selectedObject).toBeNull();
|
||||
expect(interaction.selectedRoomId).toBeNull();
|
||||
expect(interaction.selectedIndex).toBe(-1);
|
||||
expect(listener).toHaveBeenCalledWith('deselect', { roomId: 'room-1', index: 0 });
|
||||
});
|
||||
|
||||
it('clearSelection does nothing with no selection', () => {
|
||||
const listener = vi.fn();
|
||||
interaction.onChange(listener);
|
||||
interaction.clearSelection();
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('keyboard shortcuts', () => {
|
||||
function keyDown(key, opts = {}) {
|
||||
interaction._onKeyDown({
|
||||
key,
|
||||
ctrlKey: opts.ctrl || false,
|
||||
metaKey: opts.meta || false,
|
||||
shiftKey: opts.shift || false,
|
||||
target: { tagName: opts.tagName || 'BODY' },
|
||||
preventDefault: vi.fn()
|
||||
});
|
||||
}
|
||||
|
||||
it('Ctrl+Z triggers undo', () => {
|
||||
state.moveFurniture('room-1', 0, { x: 99 });
|
||||
keyDown('z', { ctrl: true });
|
||||
expect(state.getFurniture('room-1', 0).position.x).toBe(1);
|
||||
});
|
||||
|
||||
it('Ctrl+Shift+Z triggers redo', () => {
|
||||
state.moveFurniture('room-1', 0, { x: 99 });
|
||||
state.undo();
|
||||
keyDown('Z', { ctrl: true, shift: true });
|
||||
expect(state.getFurniture('room-1', 0).position.x).toBe(99);
|
||||
});
|
||||
|
||||
it('Ctrl+Y triggers redo', () => {
|
||||
state.moveFurniture('room-1', 0, { x: 99 });
|
||||
state.undo();
|
||||
keyDown('y', { ctrl: true });
|
||||
expect(state.getFurniture('room-1', 0).position.x).toBe(99);
|
||||
});
|
||||
|
||||
it('Escape clears selection and returns to view mode', () => {
|
||||
const mesh = new Group();
|
||||
mesh.userData = { roomId: 'room-1', furnitureIndex: 0, catalogId: 'chair', itemName: 'Chair' };
|
||||
interaction.select(mesh);
|
||||
|
||||
keyDown('Escape');
|
||||
expect(interaction.selectedObject).toBeNull();
|
||||
expect(interaction.mode).toBe('view');
|
||||
});
|
||||
|
||||
it('Delete removes selected furniture', () => {
|
||||
const mesh = new Group();
|
||||
mesh.userData = { roomId: 'room-1', furnitureIndex: 0, catalogId: 'chair', itemName: 'Chair' };
|
||||
interaction.select(mesh);
|
||||
|
||||
keyDown('Delete');
|
||||
expect(state.getRoomFurniture('room-1')).toHaveLength(1);
|
||||
// The chair (index 0) was removed; table remains
|
||||
expect(state.getFurniture('room-1', 0).catalogId).toBe('table');
|
||||
});
|
||||
|
||||
it('R rotates selected furniture by -90 degrees', () => {
|
||||
const mesh = new Group();
|
||||
mesh.userData = { roomId: 'room-1', furnitureIndex: 0, catalogId: 'chair', itemName: 'Chair' };
|
||||
interaction.select(mesh);
|
||||
|
||||
keyDown('r');
|
||||
expect(state.getFurniture('room-1', 0).rotation).toBe(270); // (0 + (-90) + 360) % 360
|
||||
});
|
||||
|
||||
it('Shift+R rotates selected furniture by +90 degrees', () => {
|
||||
const mesh = new Group();
|
||||
mesh.userData = { roomId: 'room-1', furnitureIndex: 0, catalogId: 'chair', itemName: 'Chair' };
|
||||
interaction.select(mesh);
|
||||
|
||||
keyDown('R', { shift: true });
|
||||
expect(state.getFurniture('room-1', 0).rotation).toBe(90);
|
||||
});
|
||||
|
||||
it('ignores shortcuts when focused on input', () => {
|
||||
const listener = vi.fn();
|
||||
state.onChange(listener);
|
||||
state.moveFurniture('room-1', 0, { x: 99 });
|
||||
listener.mockClear();
|
||||
|
||||
keyDown('z', { ctrl: true, tagName: 'INPUT' });
|
||||
// Undo should NOT have fired
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('selection-requiring shortcuts do nothing without selection', () => {
|
||||
keyDown('Delete');
|
||||
keyDown('r');
|
||||
// Should not throw, no furniture removed
|
||||
expect(state.getRoomFurniture('room-1')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onChange / observer', () => {
|
||||
it('registers and unregisters listeners', () => {
|
||||
const listener = vi.fn();
|
||||
const unsub = interaction.onChange(listener);
|
||||
interaction.setMode('select');
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
|
||||
unsub();
|
||||
interaction.setMode('view');
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('snap settings', () => {
|
||||
it('defaults to snap enabled at 0.25m', () => {
|
||||
expect(interaction.snapEnabled).toBe(true);
|
||||
expect(interaction.snapSize).toBe(0.25);
|
||||
});
|
||||
});
|
||||
|
||||
describe('_buildRoomBounds', () => {
|
||||
it('populates room bounds from house data', () => {
|
||||
interaction._buildRoomBounds();
|
||||
const bounds = interaction._roomBounds.get('room-1');
|
||||
expect(bounds).toEqual({
|
||||
minX: 0, maxX: 5,
|
||||
minZ: 0, maxZ: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('handles missing house data', () => {
|
||||
mockRenderer.houseData = null;
|
||||
interaction._buildRoomBounds();
|
||||
expect(interaction._roomBounds.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dispose', () => {
|
||||
it('does not throw', () => {
|
||||
expect(() => interaction.dispose()).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user