import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; /** * dashboard-store.test — quick-260916-bwo (Marker-Umrechnung) + erweitert um * quick-260923-ad9 (Task 3, Dashboard-Reiter). * * Sechs Tests (1-6) pinnen weiterhin die Anbindung der einmaligen * Umrechnung im Store — `loadDashboard`/`selectDashboard` rechnen alte * Anordnungen um und speichern SOFORT mit Marker; `saveLayout` traegt den * Marker bei JEDEM Speichern (T-BWO-02); der Zustand selbst bleibt * markerfrei; `addWidget` legt neue Eintraege in den verdoppelten * Vorgabegroessen an. Signaturen sind an die Reiter-Kennung angepasst * (`fetchLayout(dashboardId)`, `saveLayout(dashboardId, layouts)`, * `addWidget(dashboardId, widgetType)`), das gepruefte Verhalten ist * dasselbe. * * Ab Test 7: Reiter-Verwaltung — Laden holt zuerst die Reiterliste, macht * den ersten aktiv und laedt erst danach dessen Inhalt; ein Reiterwechsel * schreibt eine ungespeicherte Anordnung zuerst fuer den ALTEN Reiter; * Anlegen/Umbenennen/Loeschen pflegen die Reiterliste und den aktiven * Reiter. */ vi.mock('@/lib/dashboard-api', () => ({ fetchDashboards: vi.fn(), createDashboardTab: vi.fn(), renameDashboardTab: vi.fn(), deleteDashboardTab: vi.fn(), reorderDashboardTabs: vi.fn(), fetchLayout: vi.fn(), fetchWidgets: vi.fn(), saveLayout: vi.fn(), addWidget: vi.fn(), removeWidget: vi.fn(), updateWidgetConfig: vi.fn(), })); import * as api from '@/lib/dashboard-api'; const { useDashboardStore } = await import('./dashboard-store'); const EMPTY = { lg: [], md: [], sm: [], xs: [], xxs: [] }; const DASH_1 = { id: 'dash-1', name: 'Dashboard', position: 0 }; const DASH_2 = { id: 'dash-2', name: 'Dashboard 2', position: 1 }; beforeEach(() => { useDashboardStore.setState({ dashboards: [], activeDashboardId: null, isSwitchingDashboard: false, layouts: { lg: [], md: [], sm: [], xs: [], xxs: [] }, widgets: [], isEditMode: false, isDirty: false, isLoading: false, error: null, }); vi.clearAllMocks(); vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1]); vi.mocked(api.fetchWidgets).mockResolvedValue([]); vi.mocked(api.fetchLayout).mockResolvedValue({ ...EMPTY }); vi.mocked(api.saveLayout).mockResolvedValue(undefined); }); afterEach(() => { vi.restoreAllMocks(); }); describe('dashboard-store — einmalige Umrechnung mit Marker (quick-260916-bwo)', () => { it('Test 1: alte Anordnung wird beim Laden umgerechnet und SOFORT fuer den ersten Reiter mit Marker gespeichert', async () => { vi.mocked(api.fetchLayout).mockResolvedValue({ lg: [{ i: 'a', x: 1, y: 1, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [], }); await useDashboardStore.getState().loadDashboard(); const state = useDashboardStore.getState(); expect(state.layouts.lg[0]).toEqual({ i: 'a', x: 4, y: 2, w: 8, h: 4 }); expect(Object.keys(state.layouts)).not.toContain('__gridVersion'); expect(api.saveLayout).toHaveBeenCalledTimes(1); expect(api.saveLayout).toHaveBeenCalledWith( 'dash-1', expect.objectContaining({ __gridVersion: 3, lg: [{ i: 'a', x: 4, y: 2, w: 8, h: 4 }] }), ); expect(state.isDirty).toBe(false); expect(state.isLoading).toBe(false); expect(state.error).toBeNull(); }); it('Test 2: markierte Anordnung bleibt unveraendert, kein Speichern, kein Marker im Zustand', async () => { vi.mocked(api.fetchLayout).mockResolvedValue({ lg: [{ i: 'a', x: 1, y: 1, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [], __gridVersion: 3, }); await useDashboardStore.getState().loadDashboard(); const state = useDashboardStore.getState(); expect(state.layouts.lg[0]).toEqual({ i: 'a', x: 1, y: 1, w: 2, h: 2 }); expect(api.saveLayout).not.toHaveBeenCalled(); expect(Object.keys(state.layouts)).not.toContain('__gridVersion'); }); it('Test 3: leere Vorgabe-Anordnung ohne Marker -> kein Speichern', async () => { vi.mocked(api.fetchLayout).mockResolvedValue({ ...EMPTY }); await useDashboardStore.getState().loadDashboard(); expect(api.saveLayout).not.toHaveBeenCalled(); expect(useDashboardStore.getState().layouts).toEqual(EMPTY); }); it('Test 4: jedes Speichern traegt den Marker (T-BWO-02) unter der aktiven Reiter-Kennung, der Zustand bleibt markerfrei', async () => { await useDashboardStore.getState().loadDashboard(); vi.mocked(api.saveLayout).mockClear(); const layouts = { lg: [{ i: 'a', x: 2, y: 2, w: 4, h: 4 }], md: [], sm: [], xs: [], xxs: [] }; useDashboardStore.getState().updateLayouts(layouts); expect(useDashboardStore.getState().isDirty).toBe(true); await useDashboardStore.getState().saveLayout(); expect(api.saveLayout).toHaveBeenCalledTimes(1); expect(api.saveLayout).toHaveBeenCalledWith( 'dash-1', expect.objectContaining({ __gridVersion: 3, ...layouts }), ); expect(useDashboardStore.getState().isDirty).toBe(false); expect(Object.keys(useDashboardStore.getState().layouts)).not.toContain('__gridVersion'); }); it('Test 5: scheitert das Sofort-Speichern, bleibt der Zustand umgerechnet, kein Fehlerzustand, console.error einmal', async () => { vi.mocked(api.fetchLayout).mockResolvedValue({ lg: [{ i: 'a', x: 1, y: 1, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [], }); vi.mocked(api.saveLayout).mockRejectedValue(new Error('PUT failed')); const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); await expect(useDashboardStore.getState().loadDashboard()).resolves.toBeUndefined(); const state = useDashboardStore.getState(); expect(state.layouts.lg[0].x).toBe(4); expect(state.error).toBeNull(); expect(state.isLoading).toBe(false); expect(errorSpy).toHaveBeenCalledTimes(1); }); it('Test 6: neues Widget wird in den Vorgabegroessen des 48er-Rasters (quick-260929-dmx) fuer den aktiven Reiter angelegt', async () => { await useDashboardStore.getState().loadDashboard(); vi.mocked(api.addWidget).mockResolvedValue({ id: 'n1', widgetType: 'clock', config: {} }); await useDashboardStore.getState().addWidget('clock'); const state = useDashboardStore.getState(); expect(api.addWidget).toHaveBeenCalledWith('dash-1', 'clock'); expect(state.layouts.lg).toContainEqual({ i: 'n1', x: 0, y: 0, w: 8, h: 4 }); expect(state.isDirty).toBe(true); }); }); describe('dashboard-store — Reiter laden (quick-260923-ad9, Task 3)', () => { it('Test 7: holt zuerst die Reiterliste, macht den ERSTEN aktiv und laedt erst danach dessen Kacheln und Anordnung', async () => { vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1, DASH_2]); const callOrder: string[] = []; vi.mocked(api.fetchDashboards).mockImplementation(async () => { callOrder.push('tabs'); return [DASH_1, DASH_2]; }); vi.mocked(api.fetchLayout).mockImplementation(async () => { callOrder.push('layout'); return { ...EMPTY }; }); vi.mocked(api.fetchWidgets).mockImplementation(async () => { callOrder.push('widgets'); return []; }); await useDashboardStore.getState().loadDashboard(); expect(callOrder[0]).toBe('tabs'); expect(callOrder.slice(1).sort()).toEqual(['layout', 'widgets']); expect(api.fetchLayout).toHaveBeenCalledWith('dash-1'); expect(api.fetchWidgets).toHaveBeenCalledWith('dash-1'); expect(useDashboardStore.getState().activeDashboardId).toBe('dash-1'); expect(useDashboardStore.getState().dashboards).toEqual([DASH_1, DASH_2]); }); it('Test 8: zweimaliges Aufrufen von loadDashboard hintereinander loest nur EINEN Abruf der Reiterliste aus', async () => { const first = useDashboardStore.getState().loadDashboard(); const second = useDashboardStore.getState().loadDashboard(); await Promise.all([first, second]); expect(api.fetchDashboards).toHaveBeenCalledTimes(1); }); }); describe('dashboard-store — Reiterwechsel (quick-260923-ad9, Task 3)', () => { it('Test 9: ein Wechsel mit ungespeicherter Anordnung schreibt die Anordnung ZUERST fuer den ALTEN Reiter', async () => { vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1, DASH_2]); await useDashboardStore.getState().loadDashboard(); useDashboardStore.getState().updateLayouts({ lg: [{ i: 'x', x: 0, y: 0, w: 1, h: 1 }], md: [], sm: [], xs: [], xxs: [] }); vi.mocked(api.saveLayout).mockClear(); vi.mocked(api.fetchLayout).mockResolvedValue({ ...EMPTY }); vi.mocked(api.fetchWidgets).mockResolvedValue([]); await useDashboardStore.getState().selectDashboard('dash-2'); expect(api.saveLayout).toHaveBeenCalledWith('dash-1', expect.anything()); expect(useDashboardStore.getState().activeDashboardId).toBe('dash-2'); }); it('Test 10: nach dem Wechsel stehen im Zustand AUSSCHLIESSLICH Kacheln und Anordnung des neuen Reiters', async () => { vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1, DASH_2]); await useDashboardStore.getState().loadDashboard(); vi.mocked(api.fetchLayout).mockResolvedValue({ lg: [{ i: 'only-on-dash-2', x: 0, y: 0, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [], __gridVersion: 3, }); vi.mocked(api.fetchWidgets).mockResolvedValue([{ id: 'w-on-dash-2', widgetType: 'clock', config: {} }]); await useDashboardStore.getState().selectDashboard('dash-2'); const state = useDashboardStore.getState(); expect(state.widgets).toEqual([{ id: 'w-on-dash-2', widgetType: 'clock', config: {} }]); expect(state.layouts.lg).toEqual([{ i: 'only-on-dash-2', x: 0, y: 0, w: 2, h: 2 }]); }); it('Test 11: ein Wechsel auf den bereits aktiven Reiter tut nichts', async () => { await useDashboardStore.getState().loadDashboard(); vi.mocked(api.fetchLayout).mockClear(); vi.mocked(api.fetchWidgets).mockClear(); await useDashboardStore.getState().selectDashboard('dash-1'); expect(api.fetchLayout).not.toHaveBeenCalled(); expect(api.fetchWidgets).not.toHaveBeenCalled(); }); }); describe('dashboard-store — Reiter anlegen/umbenennen/loeschen (quick-260923-ad9, Task 3)', () => { it('Test 12: createDashboard haengt den neuen Reiter hinten an, macht ihn aktiv, die Kachelflaeche ist leer', async () => { await useDashboardStore.getState().loadDashboard(); vi.mocked(api.createDashboardTab).mockResolvedValue(DASH_2); vi.mocked(api.fetchLayout).mockResolvedValue({ ...EMPTY }); vi.mocked(api.fetchWidgets).mockResolvedValue([]); await useDashboardStore.getState().createDashboard(); const state = useDashboardStore.getState(); expect(state.dashboards.map((d) => d.id)).toEqual(['dash-1', 'dash-2']); expect(state.activeDashboardId).toBe('dash-2'); expect(state.widgets).toEqual([]); }); it('Test 13: renameDashboard aktualisiert den Namen in der Reiterliste', async () => { await useDashboardStore.getState().loadDashboard(); vi.mocked(api.renameDashboardTab).mockResolvedValue({ id: 'dash-1', name: 'Finanzen', position: 0 }); await useDashboardStore.getState().renameDashboard('dash-1', 'Finanzen'); expect(useDashboardStore.getState().dashboards[0].name).toBe('Finanzen'); }); it('Test 14: deleteDashboard des AKTIVEN Reiters macht den dann ersten verbleibenden Reiter aktiv und laedt dessen Inhalt', async () => { vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1, DASH_2]); await useDashboardStore.getState().loadDashboard(); vi.mocked(api.deleteDashboardTab).mockResolvedValue(undefined); vi.mocked(api.fetchLayout).mockResolvedValue({ ...EMPTY }); vi.mocked(api.fetchWidgets).mockResolvedValue([{ id: 'w-on-dash-2', widgetType: 'clock', config: {} }]); await useDashboardStore.getState().deleteDashboard('dash-1'); const state = useDashboardStore.getState(); expect(state.dashboards.map((d) => d.id)).toEqual(['dash-2']); expect(state.activeDashboardId).toBe('dash-2'); expect(state.widgets).toEqual([{ id: 'w-on-dash-2', widgetType: 'clock', config: {} }]); }); it('Test 15: deleteDashboard eines INAKTIVEN Reiters aendert den aktiven Reiter nicht', async () => { vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1, DASH_2]); await useDashboardStore.getState().loadDashboard(); vi.mocked(api.deleteDashboardTab).mockResolvedValue(undefined); vi.mocked(api.fetchLayout).mockClear(); await useDashboardStore.getState().deleteDashboard('dash-2'); expect(useDashboardStore.getState().activeDashboardId).toBe('dash-1'); expect(useDashboardStore.getState().dashboards.map((d) => d.id)).toEqual(['dash-1']); expect(api.fetchLayout).not.toHaveBeenCalled(); }); }); describe('dashboard-store — Reiter per Ziehen umsortieren (quick-260923-ad9, Task 4)', () => { it('Test 16: setzt die neue Reihenfolge SOFORT optimistisch, sendet die vollstaendige Kennungsliste, der aktive Reiter bleibt aktiv', async () => { vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1, DASH_2]); await useDashboardStore.getState().loadDashboard(); vi.mocked(api.reorderDashboardTabs).mockResolvedValue([DASH_2, DASH_1]); const promise = useDashboardStore.getState().reorderDashboards(['dash-2', 'dash-1']); // Optimistisch VOR der Antwort des Servers gesetzt. expect(useDashboardStore.getState().dashboards.map((d) => d.id)).toEqual(['dash-2', 'dash-1']); expect(useDashboardStore.getState().activeDashboardId).toBe('dash-1'); await promise; expect(api.reorderDashboardTabs).toHaveBeenCalledWith(['dash-2', 'dash-1']); expect(useDashboardStore.getState().dashboards).toEqual([DASH_2, DASH_1]); expect(useDashboardStore.getState().activeDashboardId).toBe('dash-1'); }); it('Test 17: schlaegt das Speichern fehl, steht die VORHERIGE Reihenfolge wieder im Zustand', async () => { vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1, DASH_2]); await useDashboardStore.getState().loadDashboard(); vi.mocked(api.reorderDashboardTabs).mockRejectedValue(new Error('PUT failed')); const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); await useDashboardStore.getState().reorderDashboards(['dash-2', 'dash-1']); expect(useDashboardStore.getState().dashboards).toEqual([DASH_1, DASH_2]); expect(errorSpy).toHaveBeenCalledTimes(1); }); });