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(); }); }); });