Files
tessera-ctl/apps/web/src/lib/stores/dashboard-store.test.ts
T
schalli 9c9e1420fe feat(260929-dmx): Widget-Breiten im 48er-Raster, Kalender schmaler ziehbar
- minW/defaultW aller Widgets verdoppelt (gleiche Bildschirmbreite)
- Kalender minW 8 (rund 250 px), defaultW 16
- Test: bestehender Kalender bekommt das neue Minimum in jedem Breakpoint

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 09:53:08 +02:00

323 lines
14 KiB
TypeScript

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