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