Files
tessera-ctl/apps/web/src/components/dashboard/dashboard-grid.test.tsx
T
schalli 8792819e34
Tessera CI/CD / Lint & Type Check (push) Successful in 54s
Tessera CI/CD / Tests (push) Successful in 1m1s
Tessera CI/CD / Build & Publish Images (push) Successful in 2m58s
fix(quick-260916-dyv): Rechner-Mindesthoehe 10 statt 9 Zeilen — sechs Tastenreihen, unterste Reihe war bei 9 um 25 px abgeschnitten (Browser-Messung)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
2026-09-16 10:57:55 +02:00

391 lines
15 KiB
TypeScript

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<string, Record<string, string>> = {
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<string, unknown> | null }));
vi.mock('react-grid-layout', async (importOriginal) => ({
...(await importOriginal<typeof import('react-grid-layout')>()),
Responsive: (props: Record<string, unknown> & { children: React.ReactNode }) => {
captured.props = props;
return <div data-testid="responsive-grid">{props.children}</div>;
},
}));
// 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<string, unknown> }>,
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(
<DashboardGrid
layouts={{ lg: [], md: [], sm: [], xs: [], xxs: [] }}
widgets={[]}
isEditMode={false}
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
/>,
);
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(
<DashboardGrid
layouts={layouts}
widgets={widgets}
isEditMode={false}
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
/>,
);
// 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(
<DashboardGrid
layouts={layouts}
widgets={widgets}
isEditMode={true}
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
/>,
);
// 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(
<DashboardGrid
layouts={{ lg: [{ i: 'inst-1', x: 0, y: 0, w: 4, h: 4 }], md: [], sm: [], xs: [], xxs: [] }}
widgets={[{ id: 'inst-1', widgetType: 'clock', config: {} }]}
isEditMode={false}
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
/>,
);
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(
<DashboardGrid
layouts={{ lg: [], md: [], sm: [], xs: [], xxs: [] }}
widgets={[{ id: 'inst-3', widgetType: 'clock', config: {} }]}
isEditMode={false}
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
/>,
);
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<Record<string, unknown>>).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(
<DashboardGrid
layouts={layouts}
widgets={[
{ id: 'inst-1', widgetType: 'clock', config: {} },
{ id: 'inst-x', widgetType: 'unknown', config: {} },
]}
isEditMode={false}
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
/>,
);
const passed = captured.props?.layouts as Record<string, Array<Record<string, unknown>>>;
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(
<DashboardGrid
layouts={layouts}
widgets={[
{ id: 'calc-1', widgetType: 'calculator', config: {} },
{ id: 'srch-1', widgetType: 'search', config: {} },
]}
isEditMode={false}
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
/>,
);
const passed = captured.props?.layouts as Record<string, Array<Record<string, unknown>>>;
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(
<DashboardGrid layouts={layouts} widgets={widgets} isEditMode={true} onLayoutChange={vi.fn()} onRemoveWidget={vi.fn()} />,
);
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(
<DashboardGrid layouts={layouts} widgets={widgets} isEditMode={false} onLayoutChange={vi.fn()} onRemoveWidget={vi.fn()} />,
);
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(
<DashboardGrid
layouts={{ lg: [{ i: 'inst-2', x: 0, y: 0, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [] }}
widgets={[{ id: 'inst-2', widgetType: 'clock', config: {} }]}
isEditMode={true}
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
/>,
);
const compactor = captured.props?.compactor as {
type: unknown;
allowOverlap: boolean;
preventCollision?: boolean;
compact: (layout: Array<Record<string, unknown>>, cols: number) => Array<Record<string, unknown>>;
};
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(
<DashboardGrid layouts={layouts} widgets={widgets} isEditMode={true} onLayoutChange={vi.fn()} onRemoveWidget={vi.fn()} />,
);
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(
<DashboardGrid layouts={layouts} widgets={widgets} isEditMode={false} onLayoutChange={vi.fn()} onRemoveWidget={vi.fn()} />,
);
expect(screen.queryByTitle('Drag the tile to move it')).toBeNull();
expect(document.querySelector('.widget-drag-handle')).toBeNull();
});
});