dc992c9bc6
- WIDGET_CONSTRAINTS: Minima aus dem Innenaufbau gerechnet (clock 2/2, search 6/2, calendar 3/3, note 4/4, calculator 3/9, favorites 3/3, link 3/2, stopwatch 4/3), Vorgaben unveraendert; Test A pinnt alle 32 Werte per toEqual - dashboard-grid.tsx: effectiveLayouts (useMemo) ueberschreibt minW/minH jedes gespeicherten Eintrags in jedem Breakpoint aus den Konstanten und hebt zu kleine w/h auf das Minimum an — RGL 2.2.3 nimmt gespeicherte Eintraege woertlich (synchronizeLayoutWithChildren) und liest data-grid nur ohne Eintrag - dashboard-grid.test.tsx: Test 5 angepasst, Test 9 (Ueberschreibung je Breakpoint, unbekannter Typ unveraendert) und Test 9b (h 8 -> 9, w >= minW bleibt) - stopwatch-widget.tsx: Bedienleiste px-2 py-1 text-xs, Zeile gap-1 py-1, damit Stop + Runde + Reset in 4 Spalten passen und die Kachel auf 4x3 schrumpfen kann Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
277 lines
9.8 KiB
TypeScript
277 lines
9.8 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',
|
|
'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.
|
|
const captured = vi.hoisted(() => ({ props: null as Record<string, unknown> | null }));
|
|
vi.mock('react-grid-layout', () => ({
|
|
Responsive: (props: Record<string, unknown> & { children: React.ReactNode }) => {
|
|
captured.props = props;
|
|
return <div data-testid="responsive-grid">{props.children}</div>;
|
|
},
|
|
noCompactor: () => null,
|
|
}));
|
|
|
|
// 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 -> 9, 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: 9, minW: 3, minH: 9 });
|
|
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 -> 3x9 (w schon = minW, h angehoben).
|
|
expect(passed.md[0]).toEqual({ i: 'calc-1', x: 0, y: 0, w: 3, h: 9, minW: 3, minH: 9 });
|
|
// Eingabe unveraendert (keine Mutation des Store-Objekts).
|
|
expect(layouts.lg[0].h).toBe(8);
|
|
expect(layouts.lg[0].minH).toBe(8);
|
|
});
|
|
});
|