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.
373 lines
12 KiB
JavaScript
373 lines
12 KiB
JavaScript
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
|
|
});
|
|
});
|
|
});
|