Files
tessera-ctl/apps/web/src/components/dashboard/dashboard-tabs.test.tsx
T
schalli 7416a925a6 feat(260924-h7x): Dashboard-Reiter als Umschalter in der Kopfzeile
- Kopfzeile bekommt Einhaengepunkt header-center-slot; auf / entfaellt der Text Startseite
- DashboardTabs rendert per createPortal dorthin, eigene Zeile ueber dem Raster entfaellt
- eingelassene Spur mit erhabenem aktivem Reiter, waagrecht scrollbar mit weicher Randausblendung
- role=tablist/tab, Pfeiltasten/Pos1/Ende, sichtbarer Fokusring, aktiver Reiter wird ins Bild gescrollt
- Plus-Knopf ausserhalb der Spur, Loeschdialog am Dokumentkoerper, Ziehhinweis als sr-only/Tooltip

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

435 lines
17 KiB
TypeScript

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<string, string>) => {
const translations: Record<string, Record<string, string>> = {
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<Parameters<typeof DashboardTabs>[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(<DashboardTabs {...defaultProps()} />);
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(<DashboardTabs {...defaultProps({ onSelect })} />);
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(<DashboardTabs {...defaultProps()} />);
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(<DashboardTabs {...defaultProps({ isEditMode: false })} />);
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(<DashboardTabs {...defaultProps({ isEditMode: true })} />);
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(<DashboardTabs {...defaultProps({ dashboards: [DASHBOARDS[0]], isEditMode: true })} />);
expect(screen.queryByLabelText('Dashboard löschen')).not.toBeInTheDocument();
});
it('Test 7: der Hinzufuegen-Knopf ruft onCreate auf', () => {
const onCreate = vi.fn();
render(<DashboardTabs {...defaultProps({ isEditMode: true, onCreate })} />);
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(<DashboardTabs {...defaultProps({ isEditMode: true, onRename })} />);
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(<DashboardTabs {...defaultProps({ isEditMode: true, onRename })} />);
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(<DashboardTabs {...defaultProps({ isEditMode: true })} />);
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(<DashboardTabs {...defaultProps({ isEditMode: true, onDelete })} />);
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(<DashboardTabs {...defaultProps({ isEditMode: true, onDelete })} />);
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(<DashboardTabs {...defaultProps({ onReorder, onSelect })} />);
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(<DashboardTabs {...defaultProps({ dashboards: DASHBOARDS, onReorder })} />);
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(<DashboardTabs {...defaultProps({ dashboards: DASHBOARDS, onReorder })} />);
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(<DashboardTabs {...defaultProps({ dashboards: DASHBOARDS, onReorder })} />);
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(<DashboardTabs {...defaultProps({ dashboards: THREE_DASHBOARDS, onReorder })} />);
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(
<DashboardTabs
{...defaultProps({ dashboards: DASHBOARDS, activeDashboardId: 'd1', onReorder: vi.fn() })}
/>,
);
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(<DashboardTabs {...defaultProps({ dashboards: DASHBOARDS, onSelect, onReorder })} />);
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(<DashboardTabs {...defaultProps({ dashboards: DASHBOARDS })} />);
expect(
screen.getByText('Ziehen Sie einen Reiter, um ihn zum Standard zu machen.'),
).toBeInTheDocument();
rerender(<DashboardTabs {...defaultProps({ dashboards: [DASHBOARDS[0]] })} />);
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(
<DashboardTabs {...defaultProps({ dashboards: DASHBOARDS, isEditMode: false, onReorder })} />,
);
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(<DashboardTabs {...defaultProps()} />);
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(<DashboardTabs {...defaultProps()} />);
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(<DashboardTabs {...defaultProps({ dashboards: THREE_DASHBOARDS, onSelect })} />);
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(<DashboardTabs {...defaultProps({ dashboards: [DASHBOARDS[0]], isEditMode: true })} />);
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(<DashboardTabs {...defaultProps({ isEditMode: true })} />);
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(<DashboardTabs {...defaultProps({ isEditMode: true })} />);
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);
});
});