import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; /** * Mock next-intl mit einfacher `{param}`-Ersetzung — anders als das * schlichtere Muster in `widget-catalog-modal.test.tsx` (das Parameter * ignoriert), weil Test 8 unten die eingesetzte Reiterbezeichnung im * Loeschdialog prueft. */ vi.mock('next-intl', () => ({ useTranslations: (namespace: string) => (key: string, params?: Record) => { const translations: Record> = { widgets: { 'tabs.navLabel': 'Dashboard-Reiter', 'tabs.add': 'Dashboard hinzufügen', 'tabs.renameButtonLabel': 'Dashboard umbenennen', 'tabs.renameInputLabel': 'Name des Dashboards', 'tabs.deleteButtonLabel': 'Dashboard löschen', 'tabs.deleteDialogTitle': 'Dashboard löschen', 'tabs.deleteDialogBody': 'Möchten Sie das Dashboard „{name}“ wirklich löschen?', 'tabs.dragHint': 'Ziehen Sie einen Reiter, um ihn zum Standard zu machen.', }, common: { cancel: 'Abbrechen', delete: 'Löschen', }, }; let text = translations[namespace]?.[key] ?? key; if (params) { for (const [k, v] of Object.entries(params)) { text = text.replace(`{${k}}`, v); } } return text; }, })); import { DashboardTabs } from './dashboard-tabs'; const DASHBOARDS = [ { id: 'd1', name: 'Dashboard', position: 0 }, { id: 'd2', name: 'Dashboard 2', position: 1 }, ]; const THREE_DASHBOARDS = [ { id: 'd1', name: 'Dashboard', position: 0 }, { id: 'd2', name: 'Dashboard 2', position: 1 }, { id: 'd3', name: 'Dashboard 3', position: 2 }, ]; /** * Stubbt `getBoundingClientRect` fuer JEDEN Reiter-Wrapper so, dass Reiter i * (nach seiner AKTUELLEN Stelle im DOM, `data-tab-index`) die Spanne von * i·100 bis i·100+100 belegt — echte Masse liefert jsdom hier nicht (Plan- * Vorgabe, Task 4). Die Zeigerpositionen der Tests unten rechnen gegen * genau diese Spannen. */ function stubTabRects() { vi.spyOn(HTMLDivElement.prototype, 'getBoundingClientRect').mockImplementation(function ( this: HTMLDivElement, ) { const idx = Number(this.dataset.tabIndex ?? '0'); return { x: idx * 100, y: 0, left: idx * 100, top: 0, right: idx * 100 + 100, bottom: 32, width: 100, height: 32, toJSON() { return {}; }, } as DOMRect; }); } function defaultProps(overrides: Partial[0]> = {}) { return { dashboards: DASHBOARDS, activeDashboardId: 'd1', isEditMode: false, onSelect: vi.fn(), onCreate: vi.fn(), onRename: vi.fn(), onDelete: vi.fn(), onReorder: vi.fn(), ...overrides, }; } // Seit quick-260924-h7x rendert die Leiste per Portal in den Einhaengepunkt // der Kopfzeile — der Test legt ihn so an, wie `header.tsx` ihn rendert. beforeEach(() => { const slot = document.createElement('div'); slot.id = 'header-center-slot'; document.body.appendChild(slot); }); afterEach(() => { cleanup(); document.getElementById('header-center-slot')?.remove(); vi.restoreAllMocks(); }); describe('DashboardTabs (quick-260923-ad9, Task 3)', () => { it('Test 1: rendert jeden Reiter mit seinem Namen', () => { render(); expect(screen.getByRole('tab', { name: 'Dashboard' })).toBeInTheDocument(); expect(screen.getByRole('tab', { name: 'Dashboard 2' })).toBeInTheDocument(); }); it('Test 2: ein Klick auf einen Reiter ruft onSelect mit dessen Kennung auf', () => { const onSelect = vi.fn(); render(); fireEvent.click(screen.getByRole('tab', { name: 'Dashboard 2' })); expect(onSelect).toHaveBeenCalledWith('d2'); }); it('Test 3: der aktive Reiter traegt aria-selected=true, der andere false', () => { render(); expect(screen.getByRole('tab', { name: 'Dashboard' })).toHaveAttribute('aria-selected', 'true'); expect(screen.getByRole('tab', { name: 'Dashboard 2' })).toHaveAttribute( 'aria-selected', 'false', ); }); it('Test 4: ausserhalb des Bearbeitungsmodus gibt es weder Umbenennen- noch Loeschen- noch Hinzufuegen-Knoepfe', () => { render(); expect(screen.queryByLabelText('Dashboard umbenennen')).not.toBeInTheDocument(); expect(screen.queryByLabelText('Dashboard löschen')).not.toBeInTheDocument(); expect(screen.queryByLabelText('Dashboard hinzufügen')).not.toBeInTheDocument(); }); it('Test 5: im Bearbeitungsmodus mit mehreren Reitern sind Loeschen-Knoepfe und ein Hinzufuegen-Knopf da', () => { render(); expect(screen.getAllByLabelText('Dashboard löschen')).toHaveLength(2); expect(screen.getByLabelText('Dashboard hinzufügen')).toBeInTheDocument(); }); it('Test 6: beim letzten verbleibenden Reiter wird Loeschen gar nicht erst angeboten', () => { render(); expect(screen.queryByLabelText('Dashboard löschen')).not.toBeInTheDocument(); }); it('Test 7: der Hinzufuegen-Knopf ruft onCreate auf', () => { const onCreate = vi.fn(); render(); fireEvent.click(screen.getByLabelText('Dashboard hinzufügen')); expect(onCreate).toHaveBeenCalledTimes(1); }); it('Test 8: Umbenennen-Knopf des AKTIVEN Reiters verwandelt den Namen in ein Eingabefeld; Enter uebernimmt beschnitten', () => { const onRename = vi.fn(); render(); fireEvent.click(screen.getByLabelText('Dashboard umbenennen')); const input = screen.getByLabelText('Name des Dashboards'); fireEvent.change(input, { target: { value: ' Finanzen ' } }); fireEvent.keyDown(input, { key: 'Enter' }); expect(onRename).toHaveBeenCalledWith('d1', 'Finanzen'); }); it('Test 9: Escape verwirft die Umbenennung, ohne onRename aufzurufen', () => { const onRename = vi.fn(); render(); fireEvent.click(screen.getByLabelText('Dashboard umbenennen')); const input = screen.getByLabelText('Name des Dashboards'); fireEvent.change(input, { target: { value: 'Verworfen' } }); fireEvent.keyDown(input, { key: 'Escape' }); expect(onRename).not.toHaveBeenCalled(); expect(screen.getByRole('tab', { name: 'Dashboard' })).toBeInTheDocument(); }); it('Test 10: der Umbenennen-Knopf steht nur beim AKTIVEN Reiter, nicht bei den anderen', () => { render(); expect(screen.getAllByLabelText('Dashboard umbenennen')).toHaveLength(1); }); it('Test 11: Loeschen fragt mit dem Namen des Reiters zurueck; Bestaetigen ruft onDelete auf', () => { const onDelete = vi.fn(); render(); fireEvent.click(screen.getAllByLabelText('Dashboard löschen')[1]); const dialog = screen.getByRole('alertdialog'); expect(dialog).toHaveTextContent('Dashboard 2'); fireEvent.click(within(dialog).getByRole('button', { name: 'Löschen' })); expect(onDelete).toHaveBeenCalledWith('d2'); }); it('Test 12: Abbrechen im Loeschdialog ruft onDelete NICHT auf', () => { const onDelete = vi.fn(); render(); fireEvent.click(screen.getAllByLabelText('Dashboard löschen')[0]); const dialog = screen.getByRole('alertdialog'); fireEvent.click(within(dialog).getByRole('button', { name: 'Abbrechen' })); expect(onDelete).not.toHaveBeenCalled(); expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); }); }); describe('DashboardTabs — Ziehen zum Umsortieren (quick-260923-ad9, Task 4)', () => { it('Test 13: Druecken und Loslassen OHNE nennenswerte Bewegung sendet KEINE neue Reihenfolge (der nachfolgende Klick waehlt weiterhin)', () => { stubTabRects(); const onReorder = vi.fn(); const onSelect = vi.fn(); render(); const tab = screen.getByTestId('dashboard-tab-d2'); fireEvent.pointerDown(tab, { clientX: 150, pointerId: 1, button: 0 }); fireEvent.pointerUp(tab, { clientX: 150, pointerId: 1 }); expect(onReorder).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole('tab', { name: 'Dashboard 2' })); expect(onSelect).toHaveBeenCalledWith('d2'); }); it('Test 14: Druecken, mehr als die Schwelle nach RECHTS bewegen und loslassen verschiebt den Reiter hinter seinen rechten Nachbarn', () => { stubTabRects(); const onReorder = vi.fn(); render(); const tab = screen.getByTestId('dashboard-tab-d1'); // Spanne 0..100 fireEvent.pointerDown(tab, { clientX: 10, pointerId: 1, button: 0 }); // ueber die Schwelle hinaus, weit rechts von d2s Mittelpunkt (150) fireEvent.pointerMove(tab, { clientX: 180, pointerId: 1 }); fireEvent.pointerUp(tab, { clientX: 180, pointerId: 1 }); expect(onReorder).toHaveBeenCalledWith(['d2', 'd1']); }); it('Test 15: dasselbe nach LINKS verschiebt vor den linken Nachbarn', () => { stubTabRects(); const onReorder = vi.fn(); render(); const tab = screen.getByTestId('dashboard-tab-d2'); // Spanne 100..200 fireEvent.pointerDown(tab, { clientX: 150, pointerId: 1, button: 0 }); // weit links von d1s Mittelpunkt (50) fireEvent.pointerMove(tab, { clientX: 10, pointerId: 1 }); fireEvent.pointerUp(tab, { clientX: 10, pointerId: 1 }); expect(onReorder).toHaveBeenCalledWith(['d2', 'd1']); }); it('Test 16: waehrend des Ziehens zeigt die Leiste die Vorschau-Reihenfolge; Abbruch des Zeigers verwirft sie, ohne zu senden', () => { stubTabRects(); const onReorder = vi.fn(); render(); const tab = screen.getByTestId('dashboard-tab-d1'); fireEvent.pointerDown(tab, { clientX: 10, pointerId: 1, button: 0 }); fireEvent.pointerMove(tab, { clientX: 180, pointerId: 1 }); // Vorschau: d2 steht jetzt vor d1 im DOM. const buttons = screen .getAllByRole('tab') .filter((b) => b.textContent === 'Dashboard' || b.textContent === 'Dashboard 2'); expect(buttons.map((b) => b.textContent)).toEqual(['Dashboard 2', 'Dashboard']); fireEvent.pointerCancel(tab, { pointerId: 1 }); expect(onReorder).not.toHaveBeenCalled(); const buttonsAfterCancel = screen .getAllByRole('tab') .filter((b) => b.textContent === 'Dashboard' || b.textContent === 'Dashboard 2'); expect(buttonsAfterCancel.map((b) => b.textContent)).toEqual(['Dashboard', 'Dashboard 2']); }); it('Test 17: ein Ziehen, das den ersten Reiter verdraengt, macht den vorgezogenen Reiter zum ersten in der gesendeten Liste', () => { stubTabRects(); const onReorder = vi.fn(); render(); const tab = screen.getByTestId('dashboard-tab-d3'); // Spanne 200..300 fireEvent.pointerDown(tab, { clientX: 250, pointerId: 1, button: 0 }); // weit links von d1s Mittelpunkt (50) -> wird der neue Erste fireEvent.pointerMove(tab, { clientX: 5, pointerId: 1 }); fireEvent.pointerUp(tab, { clientX: 5, pointerId: 1 }); expect(onReorder).toHaveBeenCalledWith(['d3', 'd1', 'd2']); }); it('Test 18: der aktive Reiter bleibt beim Ziehen aktiv, auch wenn er seine Position wechselt', () => { stubTabRects(); render( , ); const tab = screen.getByTestId('dashboard-tab-d1'); fireEvent.pointerDown(tab, { clientX: 10, pointerId: 1, button: 0 }); fireEvent.pointerMove(tab, { clientX: 180, pointerId: 1 }); expect(screen.getByRole('tab', { name: 'Dashboard' })).toHaveAttribute('aria-selected', 'true'); }); it('Test 19: nach einem echten Ziehen unterdrueckt der naechste Klick die Auswahl EINMAL, danach funktioniert Klicken wieder normal', () => { stubTabRects(); const onSelect = vi.fn(); const onReorder = vi.fn(); render(); const tab = screen.getByTestId('dashboard-tab-d1'); fireEvent.pointerDown(tab, { clientX: 10, pointerId: 1, button: 0 }); fireEvent.pointerMove(tab, { clientX: 180, pointerId: 1 }); fireEvent.pointerUp(tab, { clientX: 180, pointerId: 1 }); expect(onReorder).toHaveBeenCalledTimes(1); // Ein echter Browser wuerde nach dem Ziehen noch einen "click" nachreichen. fireEvent.click(screen.getByRole('tab', { name: 'Dashboard' })); expect(onSelect).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole('tab', { name: 'Dashboard' })); expect(onSelect).toHaveBeenCalledWith('d1'); }); it('Test 20: der Hinweistext zum Ziehen steht bei mehr als einem Reiter, aber nicht bei genau einem', () => { const { rerender } = render(); expect( screen.getByText('Ziehen Sie einen Reiter, um ihn zum Standard zu machen.'), ).toBeInTheDocument(); rerender(); expect( screen.queryByText('Ziehen Sie einen Reiter, um ihn zum Standard zu machen.'), ).not.toBeInTheDocument(); }); it('Test 21: Ziehen ist auch AUSSERHALB des Bearbeitungsmodus moeglich (D-09)', () => { stubTabRects(); const onReorder = vi.fn(); render( , ); const tab = screen.getByTestId('dashboard-tab-d1'); fireEvent.pointerDown(tab, { clientX: 10, pointerId: 1, button: 0 }); fireEvent.pointerMove(tab, { clientX: 180, pointerId: 1 }); fireEvent.pointerUp(tab, { clientX: 180, pointerId: 1 }); expect(onReorder).toHaveBeenCalledWith(['d2', 'd1']); }); }); describe('DashboardTabs — in der Kopfzeile (quick-260924-h7x)', () => { it('Test 22: rendert in den Einhaengepunkt der Kopfzeile, nicht an Ort und Stelle', () => { const { container } = render(); const slot = document.getElementById('header-center-slot') as HTMLElement; expect(within(slot).getByRole('navigation', { name: 'Dashboard-Reiter' })).toBeInTheDocument(); expect(within(slot).getByRole('tablist')).toBeInTheDocument(); expect(container).toBeEmptyDOMElement(); }); it('Test 23: ohne Einhaengepunkt rendert die Leiste gar nichts', () => { document.getElementById('header-center-slot')?.remove(); render(); expect(screen.queryByRole('tablist')).not.toBeInTheDocument(); expect(screen.queryByRole('tab')).not.toBeInTheDocument(); }); it('Test 24: Pfeiltasten wandern zwischen den Reitern (mit Umlauf), ohne zu waehlen; nur der aktive ist per Tab erreichbar', () => { const onSelect = vi.fn(); render(); const [t1, t2, t3] = screen.getAllByRole('tab'); expect(t1).toHaveAttribute('tabindex', '0'); expect(t2).toHaveAttribute('tabindex', '-1'); expect(t3).toHaveAttribute('tabindex', '-1'); t1.focus(); fireEvent.keyDown(t1, { key: 'ArrowRight' }); expect(t2).toHaveFocus(); fireEvent.keyDown(t2, { key: 'ArrowRight' }); expect(t3).toHaveFocus(); fireEvent.keyDown(t3, { key: 'ArrowRight' }); expect(t1).toHaveFocus(); fireEvent.keyDown(t1, { key: 'ArrowLeft' }); expect(t3).toHaveFocus(); fireEvent.keyDown(t3, { key: 'Home' }); expect(t1).toHaveFocus(); fireEvent.keyDown(t1, { key: 'End' }); expect(t3).toHaveFocus(); expect(onSelect).not.toHaveBeenCalled(); }); it('Test 25: genau ein Reiter — der Umschalter steht trotzdem, im Bearbeitungsmodus nur mit „+“ daneben', () => { render(); expect(screen.getByRole('tab', { name: 'Dashboard' })).toBeInTheDocument(); expect(screen.getByLabelText('Dashboard hinzufügen')).toBeInTheDocument(); expect(screen.queryByLabelText('Dashboard löschen')).not.toBeInTheDocument(); }); it('Test 26: „+“ sitzt ausserhalb der scrollenden Spur und bleibt so immer sichtbar', () => { render(); const tablist = screen.getByRole('tablist'); expect(tablist).not.toContainElement(screen.getByLabelText('Dashboard hinzufügen')); }); it('Test 27: der Loeschdialog haengt am Dokumentkoerper, nicht in der Kopfzeile', () => { render(); fireEvent.click(screen.getAllByLabelText('Dashboard löschen')[0]); const dialog = screen.getByRole('alertdialog'); const slot = document.getElementById('header-center-slot') as HTMLElement; expect(slot).not.toContainElement(dialog); }); });