import { describe, it, expect, vi, beforeEach } from 'vitest'; import { DesignState } from '../src/state.js'; function makeDesign() { return { name: 'Test Design', rooms: [ { roomId: 'room-a', furniture: [ { catalogId: 'chair-1', position: { x: 1, z: 2 }, rotation: 0 }, { catalogId: 'table-1', position: { x: 3, z: 4 }, rotation: 90 } ] }, { roomId: 'room-b', furniture: [ { catalogId: 'sofa-1', position: { x: 0, z: 0 }, rotation: 180 } ] } ] }; } describe('DesignState', () => { let state; beforeEach(() => { state = new DesignState(makeDesign()); }); // --- Read operations --- describe('read operations', () => { it('returns the full design via .design', () => { expect(state.design.name).toBe('Test Design'); expect(state.design.rooms).toHaveLength(2); }); it('getRoomDesign returns room by id', () => { const room = state.getRoomDesign('room-a'); expect(room).toBeDefined(); expect(room.roomId).toBe('room-a'); expect(room.furniture).toHaveLength(2); }); it('getRoomDesign returns undefined for missing room', () => { expect(state.getRoomDesign('nonexistent')).toBeUndefined(); }); it('getFurniture returns specific item', () => { const item = state.getFurniture('room-a', 0); expect(item.catalogId).toBe('chair-1'); expect(item.position).toEqual({ x: 1, z: 2 }); }); it('getFurniture returns undefined for invalid index', () => { expect(state.getFurniture('room-a', 99)).toBeUndefined(); }); it('getFurniture returns undefined for missing room', () => { expect(state.getFurniture('nonexistent', 0)).toBeUndefined(); }); it('getRoomFurniture returns array for valid room', () => { const furniture = state.getRoomFurniture('room-a'); expect(furniture).toHaveLength(2); }); it('getRoomFurniture returns empty array for missing room', () => { expect(state.getRoomFurniture('nonexistent')).toEqual([]); }); }); // --- Write operations --- describe('updateFurniture', () => { it('merges changes into furniture item', () => { state.updateFurniture('room-a', 0, { color: 'red', rotation: 45 }); const item = state.getFurniture('room-a', 0); expect(item.color).toBe('red'); expect(item.rotation).toBe(45); // original fields preserved expect(item.catalogId).toBe('chair-1'); }); it('throws for invalid room', () => { expect(() => state.updateFurniture('bad', 0, {})).toThrow('Room not found'); }); it('throws for invalid index', () => { expect(() => state.updateFurniture('room-a', 99, {})).toThrow('Furniture not found'); }); }); describe('moveFurniture', () => { it('updates position', () => { state.moveFurniture('room-a', 0, { x: 10, z: 20 }); const item = state.getFurniture('room-a', 0); expect(item.position.x).toBe(10); expect(item.position.z).toBe(20); }); it('partial position update (only x)', () => { state.moveFurniture('room-a', 0, { x: 99 }); const item = state.getFurniture('room-a', 0); expect(item.position.x).toBe(99); expect(item.position.z).toBe(2); // unchanged }); }); describe('rotateFurniture', () => { it('sets rotation', () => { state.rotateFurniture('room-a', 0, 270); expect(state.getFurniture('room-a', 0).rotation).toBe(270); }); }); describe('addFurniture', () => { it('adds to existing room and returns new index', () => { const idx = state.addFurniture('room-a', { catalogId: 'lamp-1', position: { x: 5, z: 5 }, rotation: 0 }); expect(idx).toBe(2); expect(state.getRoomFurniture('room-a')).toHaveLength(3); expect(state.getFurniture('room-a', 2).catalogId).toBe('lamp-1'); }); it('creates room entry if it does not exist', () => { const idx = state.addFurniture('room-new', { catalogId: 'desk-1', position: { x: 0, z: 0 }, rotation: 0 }); expect(idx).toBe(0); const room = state.getRoomDesign('room-new'); expect(room).toBeDefined(); expect(room.furniture).toHaveLength(1); }); it('deep clones the placement (no aliasing)', () => { const placement = { catalogId: 'x', position: { x: 1, z: 1 }, rotation: 0 }; state.addFurniture('room-a', placement); placement.position.x = 999; expect(state.getFurniture('room-a', 2).position.x).toBe(1); }); }); describe('removeFurniture', () => { it('removes and returns the item', () => { const removed = state.removeFurniture('room-a', 0); expect(removed.catalogId).toBe('chair-1'); expect(state.getRoomFurniture('room-a')).toHaveLength(1); // remaining item shifted down expect(state.getFurniture('room-a', 0).catalogId).toBe('table-1'); }); it('returns null for invalid room', () => { expect(state.removeFurniture('bad', 0)).toBeNull(); }); it('returns null for out-of-range index', () => { expect(state.removeFurniture('room-a', -1)).toBeNull(); expect(state.removeFurniture('room-a', 99)).toBeNull(); }); }); describe('loadDesign', () => { it('replaces entire design', () => { const newDesign = { name: 'New', rooms: [{ roomId: 'r1', furniture: [] }] }; state.loadDesign(newDesign); expect(state.design.name).toBe('New'); expect(state.design.rooms).toHaveLength(1); expect(state.getRoomDesign('room-a')).toBeUndefined(); expect(state.getRoomDesign('r1')).toBeDefined(); }); it('deep clones the loaded design', () => { const newDesign = { name: 'New', rooms: [] }; state.loadDesign(newDesign); newDesign.name = 'Mutated'; expect(state.design.name).toBe('New'); }); }); // --- Undo / Redo --- describe('undo / redo', () => { it('initially cannot undo or redo', () => { expect(state.canUndo).toBe(false); expect(state.canRedo).toBe(false); }); it('can undo after a mutation', () => { state.moveFurniture('room-a', 0, { x: 99 }); expect(state.canUndo).toBe(true); }); it('undo reverts last mutation', () => { state.moveFurniture('room-a', 0, { x: 99 }); state.undo(); expect(state.getFurniture('room-a', 0).position.x).toBe(1); }); it('redo re-applies after undo', () => { state.moveFurniture('room-a', 0, { x: 99 }); state.undo(); state.redo(); expect(state.getFurniture('room-a', 0).position.x).toBe(99); }); it('undo returns false when empty', () => { expect(state.undo()).toBe(false); }); it('redo returns false when empty', () => { expect(state.redo()).toBe(false); }); it('new mutation clears redo stack', () => { state.moveFurniture('room-a', 0, { x: 10 }); state.undo(); expect(state.canRedo).toBe(true); state.rotateFurniture('room-a', 0, 45); expect(state.canRedo).toBe(false); }); it('multiple undos walk back through history', () => { state.moveFurniture('room-a', 0, { x: 10 }); state.moveFurniture('room-a', 0, { x: 20 }); state.moveFurniture('room-a', 0, { x: 30 }); state.undo(); expect(state.getFurniture('room-a', 0).position.x).toBe(20); state.undo(); expect(state.getFurniture('room-a', 0).position.x).toBe(10); state.undo(); expect(state.getFurniture('room-a', 0).position.x).toBe(1); }); it('respects max undo limit', () => { // Default _maxUndo is 50 for (let i = 0; i < 55; i++) { state.moveFurniture('room-a', 0, { x: i }); } // Should have at most 50 undo entries let count = 0; while (state.canUndo) { state.undo(); count++; } expect(count).toBe(50); }); }); // --- Observers --- describe('onChange', () => { it('fires listener on mutation', () => { const listener = vi.fn(); state.onChange(listener); state.moveFurniture('room-a', 0, { x: 5 }); expect(listener).toHaveBeenCalledWith('furniture-move', { roomId: 'room-a', index: 0 }); }); it('fires correct event types', () => { const events = []; state.onChange((type) => events.push(type)); state.updateFurniture('room-a', 0, { color: 'blue' }); state.moveFurniture('room-a', 0, { x: 5 }); state.rotateFurniture('room-a', 0, 90); state.addFurniture('room-a', { catalogId: 'x', position: { x: 0, z: 0 }, rotation: 0 }); state.removeFurniture('room-a', 0); state.loadDesign({ name: 'n', rooms: [{ roomId: 'room-a', furniture: [] }] }); state.undo(); state.redo(); expect(events).toEqual([ 'furniture-update', 'furniture-move', 'furniture-rotate', 'furniture-add', 'furniture-remove', 'design-load', 'undo', 'redo' ]); }); it('returns unsubscribe function', () => { const listener = vi.fn(); const unsub = state.onChange(listener); unsub(); state.moveFurniture('room-a', 0, { x: 5 }); expect(listener).not.toHaveBeenCalled(); }); it('catches listener errors without breaking', () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const badListener = () => { throw new Error('boom'); }; const goodListener = vi.fn(); state.onChange(badListener); state.onChange(goodListener); state.moveFurniture('room-a', 0, { x: 5 }); expect(goodListener).toHaveBeenCalled(); expect(errorSpy).toHaveBeenCalled(); errorSpy.mockRestore(); }); }); // --- Serialization --- describe('toJSON', () => { it('returns a deep clone of state', () => { const json = state.toJSON(); expect(json.name).toBe('Test Design'); // mutating the clone shouldn't affect state json.name = 'Mutated'; expect(state.design.name).toBe('Test Design'); }); it('preserves structure after mutations', () => { state.addFurniture('room-a', { catalogId: 'new', position: { x: 0, z: 0 }, rotation: 0 }); const json = state.toJSON(); const room = json.rooms.find(r => r.roomId === 'room-a'); expect(room.furniture).toHaveLength(3); }); }); // --- Constructor edge cases --- describe('constructor', () => { it('deep clones initial design (no aliasing)', () => { const design = makeDesign(); const s = new DesignState(design); design.rooms[0].furniture[0].position.x = 999; expect(s.getFurniture('room-a', 0).position.x).toBe(1); }); it('handles empty rooms array', () => { const s = new DesignState({ name: 'Empty', rooms: [] }); expect(s.design.rooms).toHaveLength(0); expect(s.getRoomDesign('any')).toBeUndefined(); }); it('handles null/undefined state gracefully in _rebuildIndex', () => { const s = new DesignState({ name: 'No rooms' }); expect(s.getRoomDesign('any')).toBeUndefined(); }); }); });