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:
372
tests/catalog.test.js
Normal file
372
tests/catalog.test.js
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user