import { beforeEach, describe, expect, it, vi } from 'vitest'; const listKitchenChangesMock = vi.fn(); const listStockEventsMock = vi.fn(); const getStockEntryMock = vi.fn(); const fetchLocationsMock = vi.fn(); vi.mock('../../../src/api/stock.js', () => ({ listKitchenChanges: (...args) => listKitchenChangesMock(...args), listStockEvents: (...args) => listStockEventsMock(...args), getStockEntry: (...args) => getStockEntryMock(...args), })); vi.mock('../../../src/api/locations.js', () => ({ fetchLocations: (...args) => fetchLocationsMock(...args), })); const { dashboardPageData, renderDashboardPage } = await import('../../../src/features/dashboard/dashboard-page.js'); describe('features/dashboard/dashboard-page', () => { beforeEach(() => { listKitchenChangesMock.mockReset(); listStockEventsMock.mockReset(); getStockEntryMock.mockReset(); fetchLocationsMock.mockReset(); }); it('renders dashboard with recent changes section', () => { const html = renderDashboardPage(); expect(html).toContain('Recent changes'); expect(html).toContain('x-data="dashboardPage()"'); expect(html).toContain('Latest item and stock updates, including used and inactive stock.'); expect(html).toContain('recent-change-list'); }); it('loads recent changes on init and renders item-focused details', async () => { listKitchenChangesMock.mockResolvedValueOnce({ since: null, nextCursor: null, changes: [{ type: 'item', action: 'upsert', timestamp: '2026-04-10T10:00:00Z', item: { uuid_b64: 'u1', name: 'Rice', stock_type: 'measured', quantity: 3, uom_symbol: 'kg', level: 'good', expire_date: '2026-04-21', location_initial_uuid_b64: 'loc1', }, }], }); fetchLocationsMock.mockResolvedValueOnce({ flat: [{ uuid_b64: 'loc1', pathLabel: 'Pantry / Shelf A' }], }); const store = { isConnected: true, setActiveKitchen: vi.fn(), addAlert: vi.fn(), }; const data = dashboardPageData(store); await data.init(); expect(listKitchenChangesMock).toHaveBeenCalledWith(store, { limit: 200 }); expect(data.recentChanges).toHaveLength(1); expect(data.changesState.error).toBe(''); expect(data.changeHeadline(data.recentChanges[0])).toBe('Rice'); expect(data.changeKindLabel(data.recentChanges[0])).toBe('Updated'); expect(data.changeSubtitle(data.recentChanges[0])).toBe('Item details were updated.'); expect(data.changeDetails(data.recentChanges[0])).toEqual(expect.arrayContaining([ { label: 'Quantity', value: '3 kg' }, { label: 'Location', value: 'Pantry / Shelf A' }, ])); expect(getStockEntryMock).not.toHaveBeenCalled(); }); it('resolves stock event item context and renders stock transitions', async () => { listKitchenChangesMock.mockResolvedValueOnce({ since: null, nextCursor: null, changes: [{ type: 'stock', action: 'upsert', timestamp: '2026-04-10T10:00:00Z', stock: { id: 11, item_uuid_b64: 'item-uuid-1', quantity: 0.5, uom_symbol: 'kg', level: 'some', location_uuid_b64: 'loc2', }, }], }); listStockEventsMock.mockResolvedValueOnce([ { id: 11, quantity: 0.5, uom_symbol: 'kg', level: 'some' }, { id: 10, quantity: 1, uom_symbol: 'kg', level: 'good' }, ]); getStockEntryMock.mockResolvedValueOnce({ uuid_b64: 'item-uuid-1', name: 'Flour', stock_type: 'measured', expire_date: '2026-05-02', }); fetchLocationsMock.mockResolvedValueOnce({ flat: [{ uuid_b64: 'loc2', pathLabel: 'Pantry / Bin 2' }], }); const data = dashboardPageData({ isConnected: true, setActiveKitchen: vi.fn(), addAlert: vi.fn(), }); await data.refreshChanges(); expect(data.changeHeadline(data.recentChanges[0])).toBe('Flour'); expect(data.changeKindLabel(data.recentChanges[0])).toBe('Stock changed'); expect(data.changeStockTransition(data.recentChanges[0])).toEqual({ previous: '1 kg · Good', current: '0.5 kg · Some', }); expect(data.changeDetails(data.recentChanges[0])).toEqual(expect.arrayContaining([ { label: 'Level', value: 'Some' }, { label: 'Location', value: 'Pantry / Bin 2' }, ])); expect(getStockEntryMock).toHaveBeenCalledWith(expect.anything(), 'item-uuid-1'); expect(listStockEventsMock).toHaveBeenCalledWith(expect.anything(), 'item-uuid-1', { allowInactive: true, limit: 50, orderBy: 'id', orderDir: 'desc', }); }); it('retries stock event item lookup with allowInactive after 404', async () => { listKitchenChangesMock.mockResolvedValueOnce({ since: null, nextCursor: null, changes: [{ type: 'stock', action: 'upsert', timestamp: '2026-04-10T10:00:00Z', stock: { id: 12, item_uuid_b64: 'item-uuid-2', quantity: 1, uom_symbol: 'pcs', }, }], }); listStockEventsMock.mockResolvedValueOnce([ { id: 12, quantity: 1, uom_symbol: 'pcs' }, ]); getStockEntryMock .mockRejectedValueOnce(Object.assign(new Error('Not found'), { status: 404 })) .mockResolvedValueOnce({ uuid_b64: 'item-uuid-2', name: 'Archived pasta', stock_type: 'measured', }); fetchLocationsMock.mockResolvedValueOnce({ flat: [] }); const store = { isConnected: true, setActiveKitchen: vi.fn(), addAlert: vi.fn(), }; const data = dashboardPageData(store); await data.refreshChanges(); expect(getStockEntryMock).toHaveBeenNthCalledWith(1, store, 'item-uuid-2'); expect(getStockEntryMock).toHaveBeenNthCalledWith( 2, store, 'item-uuid-2', { allowInactive: true }, ); expect(data.changeHeadline(data.recentChanges[0])).toBe('Archived pasta'); expect(data.changeKindLabel(data.recentChanges[0])).toBe('New item'); expect(data.changeStockTransition(data.recentChanges[0])).toEqual({ previous: 'Initial stock', current: '1 pcs', }); }); it('keeps empty state when API returns no changes', async () => { listKitchenChangesMock.mockResolvedValueOnce({ since: null, nextCursor: null, changes: [], }); fetchLocationsMock.mockResolvedValueOnce({ flat: [] }); const data = dashboardPageData({ isConnected: true, setActiveKitchen: vi.fn(), addAlert: vi.fn(), }); await data.refreshChanges(); expect(data.recentChanges).toEqual([]); expect(data.changesState.error).toBe(''); }); it('captures refresh errors in async state', async () => { listKitchenChangesMock.mockRejectedValueOnce(new Error('Feed unavailable')); const data = dashboardPageData({ isConnected: true, setActiveKitchen: vi.fn(), addAlert: vi.fn(), }); await data.refreshChanges(); expect(data.changesState.error).toBe('Feed unavailable'); expect(data.recentChanges).toEqual([]); }); });