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 }); }); });