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:
347
tests/state.test.js
Normal file
347
tests/state.test.js
Normal file
@@ -0,0 +1,347 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user