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.
312 lines
9.8 KiB
JavaScript
312 lines
9.8 KiB
JavaScript
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();
|
|
});
|
|
});
|
|
});
|