import { Children, isValidElement } from 'react'; import { cleanup, render, screen } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; // Mock CSS imports that vitest cannot resolve vi.mock('react-grid-layout/css/styles.css', () => ({})); vi.mock('react-resizable/css/styles.css', () => ({})); // Mock next-intl vi.mock('next-intl', () => ({ useTranslations: (ns: string) => (key: string) => { const map: Record> = { widgets: { emptyHeading: 'No active widgets', emptyBody: 'Click edit to add widgets to your dashboard.', addWidget: 'Add widget', catalogTitle: 'Add widget', deleteTooltip: 'Remove widget', dragHint: 'Drag the tile to move it', 'clock.name': 'Clock', 'clock.description': 'Shows the current time', 'search.name': 'Search', 'search.description': 'Quick web search', 'calendar.name': 'Calendar', 'calendar.description': 'Upcoming events', 'note.name': 'Notes', 'note.description': 'Free-text notes with Markdown', }, dashboard: { title: 'Dashboard', editMode: 'Edit dashboard', saveChanges: 'Save changes', }, }; return map[ns]?.[key] ?? key; }, })); // Mock react-grid-layout Responsive to a simple passthrough for jsdom stability. // quick-260916-bwo: der Mock faengt zusaetzlich die Props ein, damit die // Raster-Konstanten (cols, rowHeight, margin, breakpoints) pruefbar sind. // quick-260916-dyv: Mock per importOriginal, damit `noCompactor` das echte // Objekt ist (Test 7 prueft compact als Identitaets-Kopie), nur Responsive ersetzt. const captured = vi.hoisted(() => ({ props: null as Record | null })); vi.mock('react-grid-layout', async (importOriginal) => ({ ...(await importOriginal()), Responsive: (props: Record & { children: React.ReactNode }) => { captured.props = props; return
{props.children}
; }, })); // Mock dashboard-api vi.mock('@/lib/dashboard-api', () => ({ fetchLayout: vi.fn().mockResolvedValue({ lg: [], md: [], sm: [], xs: [], xxs: [] }), saveLayout: vi.fn().mockResolvedValue({}), fetchWidgets: vi.fn().mockResolvedValue([]), addWidget: vi.fn(), removeWidget: vi.fn(), updateWidgetConfig: vi.fn(), })); // Mock dashboard-store const mockStore = { layouts: { lg: [], md: [], sm: [], xs: [], xxs: [] }, widgets: [] as Array<{ id: string; widgetType: string; config: Record }>, isEditMode: false, isDirty: false, setEditMode: vi.fn(), updateLayouts: vi.fn(), addWidget: vi.fn(), removeWidget: vi.fn(), loadDashboard: vi.fn(), saveLayout: vi.fn(), }; vi.mock('@/lib/stores/dashboard-store', () => ({ useDashboardStore: (selector?: (state: typeof mockStore) => unknown) => { if (selector) return selector(mockStore); return mockStore; }, })); afterEach(() => { cleanup(); vi.restoreAllMocks(); }); describe('DashboardGrid', () => { it('renders empty state heading when no widgets', async () => { const { DashboardGrid } = await import('./dashboard-grid'); render( , ); expect(screen.getByText('No active widgets')).toBeInTheDocument(); }); it('renders widget instance when widgets are provided', async () => { const widgets = [ { id: 'inst-1', widgetType: 'clock', config: { timezone: 'Europe/Berlin' } }, ]; const layouts = { lg: [{ i: 'inst-1', x: 0, y: 0, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [], }; const { DashboardGrid } = await import('./dashboard-grid'); render( , ); // Widget is rendered inside grid, keyed by instance id const grid = screen.getByTestId('responsive-grid'); expect(grid).toBeInTheDocument(); // The widget wrapper should be in the document expect(grid.querySelector('[data-widget-id="inst-1"]')).toBeInTheDocument(); }); it('shows edit affordances (delete button) when isEditMode is true', async () => { const widgets = [ { id: 'inst-2', widgetType: 'clock', config: {} }, ]; const layouts = { lg: [{ i: 'inst-2', x: 0, y: 0, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [], }; const { DashboardGrid } = await import('./dashboard-grid'); render( , ); // In edit mode, delete button and drag handle should exist expect(screen.getByLabelText('Remove widget')).toBeInTheDocument(); expect(document.querySelector('.widget-drag-handle')).toBeInTheDocument(); }); it('quick-260916-bwo Test 4: Grid-Props — 24/20/12/8/2 Spalten, rowHeight 20, margin 8, Breakpoints unveraendert, containerPadding folgt dem margin', async () => { captured.props = null; const { DashboardGrid } = await import('./dashboard-grid'); render( , ); expect(captured.props).not.toBeNull(); expect(captured.props?.cols).toEqual({ lg: 24, md: 20, sm: 12, xs: 8, xxs: 2 }); expect(captured.props?.rowHeight).toBe(20); expect(captured.props?.margin).toEqual([8, 8]); expect(captured.props?.breakpoints).toEqual({ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }); expect(captured.props?.containerPadding).toBeUndefined(); }); it('quick-260916-bwo Test 5: Widget ohne gespeicherten Eintrag bekommt die Vorgaben und die inhaltsgetriebenen Minima als data-grid', async () => { captured.props = null; const { DashboardGrid } = await import('./dashboard-grid'); render( , ); const children = Children.toArray(captured.props?.children as React.ReactNode); expect(children).toHaveLength(1); const first = children[0]; expect(isValidElement(first)).toBe(true); const props = (first as React.ReactElement>).props; // quick-260916-dyv: Uhr-Minimum 2x2 (vorher verdoppelt 4x4), Vorgabe 4x4 unveraendert. expect(props['data-grid']).toEqual({ x: 0, y: 0, w: 4, h: 4, minW: 2, minH: 2 }); }); it('quick-260916-dyv Test 9: gespeicherte minW/minH werden in JEDEM Breakpoint aus WIDGET_CONSTRAINTS ueberschrieben, x/y/w/h bleiben, unbekannte Typen und Schluessel unveraendert', async () => { captured.props = null; const { DashboardGrid } = await import('./dashboard-grid'); const layouts = { lg: [ { i: 'inst-1', x: 2, y: 4, w: 6, h: 6, minW: 8, minH: 8 }, { i: 'inst-x', x: 10, y: 0, w: 5, h: 5, minW: 7, minH: 7 }, ], md: [{ i: 'inst-1', x: 0, y: 0, w: 4, h: 4, minW: 4, minH: 4 }], sm: [], xs: [], xxs: [], }; render( , ); const passed = captured.props?.layouts as Record>>; expect(Object.keys(passed)).toEqual(Object.keys(layouts)); expect(passed.lg[0]).toEqual({ i: 'inst-1', x: 2, y: 4, w: 6, h: 6, minW: 2, minH: 2 }); expect(passed.md[0]).toEqual({ i: 'inst-1', x: 0, y: 0, w: 4, h: 4, minW: 2, minH: 2 }); expect(passed.md[0].minW).toBe(2); // Unbekannter Typ: Eintrag bleibt woertlich erhalten (kein Absturz, T-DYV-01). expect(passed.lg[1]).toEqual({ i: 'inst-x', x: 10, y: 0, w: 5, h: 5, minW: 7, minH: 7 }); expect(passed.sm).toEqual([]); }); it('quick-260916-dyv Test 9b: gespeicherte Groesse unter dem neuen Minimum wird auf das Minimum angehoben (Rechner h 8 -> 10, w bleibt wenn >= minW)', async () => { captured.props = null; const { DashboardGrid } = await import('./dashboard-grid'); const layouts = { lg: [ // Rechner: gespeichert mit h 8 (bwo-Minimum), neues Minimum 3x9 -> h wird 9, w 6 bleibt. { i: 'calc-1', x: 0, y: 0, w: 6, h: 8, minW: 4, minH: 8 }, // Suche: gespeichert mit w 4 (unter minW 6) -> w wird 6, h 4 bleibt. { i: 'srch-1', x: 6, y: 0, w: 4, h: 4, minW: 6, minH: 4 }, ], md: [{ i: 'calc-1', x: 0, y: 0, w: 3, h: 7, minW: 4, minH: 8 }], sm: [], xs: [], xxs: [], }; render( , ); const passed = captured.props?.layouts as Record>>; expect(passed.lg[0]).toEqual({ i: 'calc-1', x: 0, y: 0, w: 6, h: 10, minW: 3, minH: 10 }); expect(passed.lg[1]).toEqual({ i: 'srch-1', x: 6, y: 0, w: 6, h: 4, minW: 6, minH: 2 }); // Jeder Breakpoint: md-Rechner 3x7 -> 3x10 (w schon = minW, h angehoben). expect(passed.md[0]).toEqual({ i: 'calc-1', x: 0, y: 0, w: 3, h: 10, minW: 3, minH: 10 }); // Eingabe unveraendert (keine Mutation des Store-Objekts). expect(layouts.lg[0].h).toBe(8); expect(layouts.lg[0].minH).toBe(8); }); const CANCEL = 'input, textarea, select, button, a, [contenteditable], [data-no-drag], .widgetNoDrag'; it('quick-260916-dyv Test 6: dragConfig-Pin — handle Karte, cancel fuer Interaktives, threshold 3; resizeConfig folgt dem Bearbeitungsmodus', async () => { const { DashboardGrid } = await import('./dashboard-grid'); const widgets = [{ id: 'inst-2', widgetType: 'clock', config: {} }]; const layouts = { lg: [{ i: 'inst-2', x: 0, y: 0, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [] }; captured.props = null; render( , ); expect(captured.props?.dragConfig).toEqual({ enabled: true, handle: '.widget-drag-handle', cancel: CANCEL, threshold: 3, }); expect(captured.props?.resizeConfig).toEqual({ enabled: true }); cleanup(); captured.props = null; render( , ); expect(captured.props?.dragConfig).toEqual({ enabled: false, handle: '.widget-drag-handle', cancel: CANCEL, threshold: 3, }); expect(captured.props?.resizeConfig).toEqual({ enabled: false }); }); it('quick-260916-dyv Test 7: Compactor-Pin — echter noCompactor plus preventCollision: true, compact ist Identitaets-Kopie (freie Platzierung)', async () => { captured.props = null; const { DashboardGrid } = await import('./dashboard-grid'); render( , ); const compactor = captured.props?.compactor as { type: unknown; allowOverlap: boolean; preventCollision?: boolean; compact: (layout: Array>, cols: number) => Array>; }; expect(compactor).toMatchObject({ type: null, allowOverlap: false, preventCollision: true }); expect(typeof compactor.compact).toBe('function'); const input = [{ i: 'a', x: 0, y: 0, w: 2, h: 2 }]; const out = compactor.compact(input, 24); // Gemessen (RGL 2.2.3): noCompactor.compact = cloneLayout -> cloneLayoutItem // kopiert i/x/y/w/h unveraendert und normalisiert moved/static zu false // (minW/maxW/... als undefined). Keine Verschiebung, keine Kompaktierung. expect(out).toHaveLength(1); expect(out[0]).toMatchObject({ i: 'a', x: 0, y: 0, w: 2, h: 2, moved: false, static: false }); expect(out).not.toBe(input); expect(out[0]).not.toBe(input[0]); expect(input[0]).toEqual({ i: 'a', x: 0, y: 0, w: 2, h: 2 }); }); it('quick-260916-dyv Test 8: cancel/handle-Semantik im DOM — Karte ist Griff, Loesch-Knopf/Eingaben/widgetNoDrag passen auf cancel, Kopfleiste als Overlay nur im Bearbeitungsmodus', async () => { const mod = await import('./dashboard-grid'); const { DashboardGrid, WIDGET_DRAG_HANDLE_SELECTOR, WIDGET_DRAG_CANCEL_SELECTOR } = mod; expect(WIDGET_DRAG_HANDLE_SELECTOR).toBe('.widget-drag-handle'); expect(WIDGET_DRAG_CANCEL_SELECTOR).toBe(CANCEL); const widgets = [{ id: 'inst-2', widgetType: 'clock', config: {} }]; const layouts = { lg: [{ i: 'inst-2', x: 0, y: 0, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [] }; render( , ); const card = document.querySelector('[data-widget-id="inst-2"]') as HTMLElement; expect(card).not.toBeNull(); expect(card.matches(WIDGET_DRAG_HANDLE_SELECTOR)).toBe(true); // Die Karte selbst darf NICHT auf cancel passen, sonst zieht nichts mehr. expect(card.closest(WIDGET_DRAG_CANCEL_SELECTOR)).toBeNull(); const button = screen.getByLabelText('Remove widget'); expect(button.closest(WIDGET_DRAG_CANCEL_SELECTOR)).toBe(button); expect(button.hasAttribute('data-no-drag')).toBe(true); const input = document.createElement('input'); card.appendChild(input); expect(input.closest(WIDGET_DRAG_CANCEL_SELECTOR)).toBe(input); const noDrag = document.createElement('div'); noDrag.className = 'widgetNoDrag'; card.appendChild(noDrag); expect(noDrag.closest(WIDGET_DRAG_CANCEL_SELECTOR)).toBe(noDrag); const head = screen.getByTitle('Drag the tile to move it'); expect(card.contains(head)).toBe(true); expect(head.classList.contains('absolute')).toBe(true); expect(head.classList.contains('h-5')).toBe(true); cleanup(); render( , ); expect(screen.queryByTitle('Drag the tile to move it')).toBeNull(); expect(document.querySelector('.widget-drag-handle')).toBeNull(); }); });