Files
house-design/tests/state.test.js
m 8ac5b3f1f9 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.
2026-02-07 16:34:36 +01:00

348 lines
11 KiB
JavaScript

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