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:
m
2026-02-07 16:34:36 +01:00
parent bc94d41f2b
commit 8ac5b3f1f9
13 changed files with 2058 additions and 0 deletions

372
tests/catalog.test.js Normal file
View File

@@ -0,0 +1,372 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { CatalogPanel } from '../src/catalog.js';
function makeCatalogData() {
return {
categories: ['seating', 'tables', 'storage'],
items: [
{
id: 'chair-1', name: 'Dining Chair', category: 'seating',
dimensions: { width: 0.45, depth: 0.5, height: 0.9 },
rooms: ['wohnzimmer'],
mesh: { type: 'group', parts: [{ color: '#8b4513' }] }
},
{
id: 'table-1', name: 'Kitchen Table', category: 'tables',
dimensions: { width: 1.4, depth: 0.8, height: 0.75 },
rooms: ['küche'],
mesh: { type: 'group', parts: [{ color: '#d2b48c' }] }
},
{
id: 'ikea-shelf-1', name: 'KALLAX Shelf', category: 'storage',
dimensions: { width: 0.77, depth: 0.39, height: 1.47 },
rooms: [],
ikeaSeries: 'KALLAX',
mesh: { type: 'group', parts: [{ color: '#ffffff' }] }
},
{
id: 'ikea-chair-1', name: 'POÄNG Chair', category: 'seating',
dimensions: { width: 0.68, depth: 0.82, height: 1.0 },
rooms: ['wohnzimmer'],
ikeaSeries: 'POÄNG',
mesh: { type: 'group', parts: [{ color: '#b5651d' }] }
}
]
};
}
function makeMockRenderer(catalogData) {
const listeners = {};
return {
catalogData: catalogData || makeCatalogData(),
_catalogIndex: new Map((catalogData || makeCatalogData()).items.map(i => [i.id, i])),
container: {
addEventListener: (t, fn) => { listeners[t] = listeners[t] || []; listeners[t].push(fn); },
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
_listeners: listeners
},
houseData: {
floors: [{
id: 'eg',
rooms: [{
id: 'eg-wohnzimmer', name: 'Wohnzimmer', type: 'living',
position: { x: 0, y: 0 },
dimensions: { width: 5, length: 4 }
}]
}]
},
currentFloor: 0,
getRooms: () => [
{ id: 'eg-wohnzimmer', name: 'Wohnzimmer', nameEN: 'Living Room', type: 'living', area: 20 }
]
};
}
function makeMockState() {
return {
addFurniture: vi.fn(() => 0),
onChange: vi.fn(() => () => {})
};
}
function makeMockInteraction() {
return {
selectedRoomId: null,
onChange: vi.fn(() => () => {})
};
}
function makeContainer() {
// Minimal DOM element mock
const el = {
innerHTML: '',
className: '',
style: {},
children: [],
querySelectorAll: () => [],
querySelector: (sel) => {
if (sel === '.catalog-count') return { textContent: '' };
return null;
},
appendChild: vi.fn(function(child) { this.children.push(child); return child; }),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dataset: {}
};
return el;
}
// Create a DOM element factory for jsdom-free testing
function setupDomMock() {
const elements = [];
globalThis.document = {
createElement: (tag) => {
const el = {
tagName: tag.toUpperCase(),
className: '',
innerHTML: '',
textContent: '',
style: {},
dataset: {},
value: '',
type: '',
placeholder: '',
step: '',
min: '',
max: '',
children: [],
_listeners: {},
appendChild: vi.fn(function(child) { this.children.push(child); return child; }),
addEventListener: vi.fn(function(type, fn) {
this._listeners[type] = this._listeners[type] || [];
this._listeners[type].push(fn);
}),
removeEventListener: vi.fn(),
querySelector: function(sel) {
if (sel === '.catalog-count') return { textContent: '' };
if (sel === '.catalog-item-add') return { addEventListener: vi.fn(), removeEventListener: vi.fn() };
return null;
},
querySelectorAll: () => [],
focus: vi.fn(),
click: vi.fn()
};
elements.push(el);
return el;
}
};
return elements;
}
describe('CatalogPanel', () => {
let panel, container, mockRenderer, mockState, mockInteraction;
beforeEach(() => {
setupDomMock();
container = makeContainer();
mockRenderer = makeMockRenderer();
mockState = makeMockState();
mockInteraction = makeMockInteraction();
panel = new CatalogPanel(container, {
renderer: mockRenderer,
state: mockState,
interaction: mockInteraction
});
});
describe('initialization', () => {
it('sets default filter state', () => {
expect(panel.selectedSource).toBe('all');
expect(panel.selectedCategory).toBe('all');
expect(panel.selectedSeries).toBe('all');
expect(panel.searchQuery).toBe('');
});
it('stores renderer, state, and interaction references', () => {
expect(panel.renderer).toBe(mockRenderer);
expect(panel.state).toBe(mockState);
expect(panel.interaction).toBe(mockInteraction);
});
});
describe('_hasIkeaItems', () => {
it('returns true when IKEA items exist', () => {
expect(panel._hasIkeaItems()).toBe(true);
});
it('returns false with no IKEA items', () => {
mockRenderer.catalogData.items = mockRenderer.catalogData.items.filter(i => !i.id.startsWith('ikea-'));
expect(panel._hasIkeaItems()).toBe(false);
});
it('returns false with no catalog', () => {
mockRenderer.catalogData = null;
expect(panel._hasIkeaItems()).toBe(false);
});
});
describe('_getSourceFilteredItems', () => {
it('returns all items for "all" source', () => {
panel.selectedSource = 'all';
expect(panel._getSourceFilteredItems()).toHaveLength(4);
});
it('returns only IKEA items for "ikea" source', () => {
panel.selectedSource = 'ikea';
const items = panel._getSourceFilteredItems();
expect(items).toHaveLength(2);
expect(items.every(i => i.id.startsWith('ikea-'))).toBe(true);
});
it('returns only standard items for "standard" source', () => {
panel.selectedSource = 'standard';
const items = panel._getSourceFilteredItems();
expect(items).toHaveLength(2);
expect(items.every(i => !i.id.startsWith('ikea-'))).toBe(true);
});
it('returns empty array with no catalog', () => {
mockRenderer.catalogData = null;
expect(panel._getSourceFilteredItems()).toEqual([]);
});
});
describe('_getFilteredItems', () => {
it('filters by category', () => {
panel.selectedCategory = 'seating';
const items = panel._getFilteredItems();
expect(items).toHaveLength(2); // chair-1 + ikea-chair-1
expect(items.every(i => i.category === 'seating')).toBe(true);
});
it('filters by source + category', () => {
panel.selectedSource = 'ikea';
panel.selectedCategory = 'seating';
const items = panel._getFilteredItems();
expect(items).toHaveLength(1);
expect(items[0].id).toBe('ikea-chair-1');
});
it('filters by series', () => {
panel.selectedSource = 'ikea';
panel.selectedSeries = 'KALLAX';
const items = panel._getFilteredItems();
expect(items).toHaveLength(1);
expect(items[0].id).toBe('ikea-shelf-1');
});
it('filters by search query', () => {
panel.searchQuery = 'kallax';
const items = panel._getFilteredItems();
expect(items).toHaveLength(1);
expect(items[0].id).toBe('ikea-shelf-1');
});
it('search matches on name, id, and category', () => {
panel.searchQuery = 'chair';
const items = panel._getFilteredItems();
expect(items.length).toBeGreaterThanOrEqual(2); // chair-1, ikea-chair-1
});
it('search is case-insensitive', () => {
panel.searchQuery = 'DINING';
const items = panel._getFilteredItems();
expect(items).toHaveLength(1);
expect(items[0].id).toBe('chair-1');
});
it('combined filters narrow results', () => {
panel.selectedSource = 'standard';
panel.selectedCategory = 'tables';
panel.searchQuery = 'kitchen';
const items = panel._getFilteredItems();
expect(items).toHaveLength(1);
expect(items[0].id).toBe('table-1');
});
it('no matches returns empty array', () => {
panel.searchQuery = 'nonexistent-item-xyz';
expect(panel._getFilteredItems()).toHaveLength(0);
});
});
describe('_getTargetRoom', () => {
it('uses selectedRoomId if set', () => {
panel.selectedRoomId = 'custom-room';
expect(panel._getTargetRoom({})).toBe('custom-room');
});
it('falls back to interaction selectedRoomId', () => {
mockInteraction.selectedRoomId = 'interaction-room';
expect(panel._getTargetRoom({})).toBe('interaction-room');
});
it('matches catalog item room hints to floor rooms', () => {
const item = { rooms: ['wohnzimmer'] };
expect(panel._getTargetRoom(item)).toBe('eg-wohnzimmer');
});
it('falls back to first room when no hint matches', () => {
const item = { rooms: ['bathroom'] };
expect(panel._getTargetRoom(item)).toBe('eg-wohnzimmer');
});
it('falls back to first room with no hints', () => {
expect(panel._getTargetRoom({ rooms: [] })).toBe('eg-wohnzimmer');
});
it('returns null with no rooms available', () => {
mockRenderer.getRooms = () => [];
expect(panel._getTargetRoom({ rooms: [] })).toBeNull();
});
});
describe('_placeItem', () => {
it('calls state.addFurniture with correct placement', () => {
const item = { id: 'chair-1', rooms: ['wohnzimmer'] };
panel._placeItem(item);
expect(mockState.addFurniture).toHaveBeenCalledTimes(1);
const [roomId, placement] = mockState.addFurniture.mock.calls[0];
expect(roomId).toBe('eg-wohnzimmer');
expect(placement.catalogId).toBe('chair-1');
expect(placement.position.x).toBe(2.5); // center of 5m wide room
expect(placement.position.z).toBe(2); // center of 4m long room
expect(placement.rotation).toBe(0);
});
it('does nothing when no target room found', () => {
mockRenderer.getRooms = () => [];
panel._placeItem({ id: 'x', rooms: [] });
expect(mockState.addFurniture).not.toHaveBeenCalled();
});
});
describe('setSelectedRoom', () => {
it('sets selectedRoomId', () => {
panel.setSelectedRoom('room-x');
expect(panel.selectedRoomId).toBe('room-x');
});
it('clears with null', () => {
panel.setSelectedRoom('room-x');
panel.setSelectedRoom(null);
expect(panel.selectedRoomId).toBeNull();
});
});
describe('_addCustomItem', () => {
it('adds item to catalog', () => {
const countBefore = mockRenderer.catalogData.items.length;
panel._addCustomItem({
name: 'My Shelf', width: 1, depth: 0.5, height: 1.5,
color: '#aabbcc', category: 'storage'
});
expect(mockRenderer.catalogData.items.length).toBe(countBefore + 1);
const added = mockRenderer.catalogData.items[countBefore];
expect(added.id).toBe('custom-my-shelf');
expect(added.name).toBe('My Shelf');
expect(added.category).toBe('storage');
expect(added.dimensions).toEqual({ width: 1, depth: 0.5, height: 1.5 });
});
it('generates unique id on collision', () => {
panel._addCustomItem({ name: 'Shelf', width: 1, depth: 0.5, height: 1, color: '#000', category: 'storage' });
panel._addCustomItem({ name: 'Shelf', width: 1, depth: 0.5, height: 1, color: '#000', category: 'storage' });
const customItems = mockRenderer.catalogData.items.filter(i => i.id.startsWith('custom-shelf'));
expect(customItems).toHaveLength(2);
expect(customItems[0].id).not.toBe(customItems[1].id);
});
it('does nothing with no catalog', () => {
mockRenderer.catalogData = null;
panel._addCustomItem({ name: 'X', width: 1, depth: 1, height: 1, color: '#000', category: 'storage' });
// Should not throw
});
});
});