12214a948e
- Layout mit neuerer Rasterversion wird nie gespeichert, Bearbeiten gesperrt mit Hinweis - Kalender minW 11 (~260 px bei lg), Breiten je Breakpoint auf Spaltenzahl begrenzt - optimistische Ruecksetzung nur, wenn noch der gesetzte Wert steht - Titel-Schalter mit fester Beschriftung + aria-pressed - Hintergrund-Dialog: Fokus rein/zurueck, Tab bleibt im Dialog - Loeschen eines Bildes setzt eine darauf zeigende Hintergrund-Wahl zurueck - Uebersetzungen und CHANGELOG Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
305 lines
11 KiB
TypeScript
305 lines
11 KiB
TypeScript
import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { type AuthUser, useAuthStore } from '@/lib/stores/auth-store';
|
|
|
|
// quick-260928-ujj: der Dashboard-Hintergrund steht pro Benutzer in der
|
|
// Datenbank. Der Hook liest ihn aus dem Auth-Store (Sitzungsantwort) und
|
|
// speichert ueber die Server-Aktion; eine alte localStorage-Wahl wird
|
|
// einmalig uebernommen.
|
|
|
|
const updateDashboardBackgroundAction = vi.fn();
|
|
|
|
vi.mock('@/lib/auth-actions', () => ({
|
|
updateDashboardBackgroundAction: (...args: unknown[]) => updateDashboardBackgroundAction(...args),
|
|
}));
|
|
|
|
// quick-260930: Auswahlfenster (Fokus, geloeschtes Bild) braucht Uebersetzung,
|
|
// Erscheinungsbild und die Bilderliste.
|
|
vi.mock('next-intl', () => ({
|
|
useTranslations: () => (key: string) => key,
|
|
}));
|
|
vi.mock('next-themes', () => ({
|
|
useTheme: () => ({ resolvedTheme: 'light' }),
|
|
}));
|
|
const fetchDashboardImages = vi.fn();
|
|
vi.mock('@/lib/dashboard-images-api', () => ({
|
|
fetchDashboardImages: (...args: unknown[]) => fetchDashboardImages(...args),
|
|
uploadDashboardImage: vi.fn(),
|
|
dashboardImageSrc: (id: string) => `/api-proxy/dashboard/images/${id}`,
|
|
}));
|
|
|
|
const IMAGE_ID = '11111111-2222-3333-4444-555555555555';
|
|
|
|
const LEGACY_KEY = 'tessera.dashboardBackground.u1';
|
|
|
|
function makeUser(overrides: Partial<AuthUser> = {}): AuthUser {
|
|
return {
|
|
id: 'u1',
|
|
username: 'schalli',
|
|
displayName: 'Schalli',
|
|
role: 'USER',
|
|
tenantId: 't1',
|
|
accentColor: null,
|
|
dashboardBackground: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
async function renderBackgroundHook() {
|
|
const { useDashboardBackground } = await import('./dashboard-background');
|
|
return renderHook(() => useDashboardBackground());
|
|
}
|
|
|
|
beforeEach(() => {
|
|
updateDashboardBackgroundAction.mockReset();
|
|
updateDashboardBackgroundAction.mockResolvedValue({ success: true });
|
|
useAuthStore.setState({ user: null });
|
|
});
|
|
|
|
afterEach(() => {
|
|
window.localStorage.clear();
|
|
});
|
|
|
|
describe('useDashboardBackground (quick-260928-ujj)', () => {
|
|
it('ohne angemeldeten Benutzer: kein Hintergrund, kein Speichern', async () => {
|
|
const { result } = await renderBackgroundHook();
|
|
|
|
expect(result.current.background).toEqual({ kind: 'none' });
|
|
expect(updateDashboardBackgroundAction).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('liest die Wahl aus dem Auth-Store; null ergibt „kein Hintergrund“', async () => {
|
|
useAuthStore.setState({
|
|
user: makeUser({ dashboardBackground: { kind: 'preset', id: 'dunes' } }),
|
|
});
|
|
const { result } = await renderBackgroundHook();
|
|
expect(result.current.background).toEqual({ kind: 'preset', id: 'dunes' });
|
|
|
|
act(() => useAuthStore.setState({ user: makeUser({ dashboardBackground: null }) }));
|
|
expect(result.current.background).toEqual({ kind: 'none' });
|
|
});
|
|
|
|
it('choose() setzt den Store sofort und speichert ueber die Server-Aktion', async () => {
|
|
useAuthStore.setState({ user: makeUser({ dashboardBackground: { kind: 'none' } }) });
|
|
const { result } = await renderBackgroundHook();
|
|
|
|
act(() => {
|
|
result.current.choose({ kind: 'preset', id: 'mosaic' });
|
|
});
|
|
|
|
expect(useAuthStore.getState().user?.dashboardBackground).toEqual({
|
|
kind: 'preset',
|
|
id: 'mosaic',
|
|
});
|
|
expect(result.current.background).toEqual({ kind: 'preset', id: 'mosaic' });
|
|
expect(updateDashboardBackgroundAction).toHaveBeenCalledWith({ kind: 'preset', id: 'mosaic' });
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
'Fehlerantwort',
|
|
() =>
|
|
updateDashboardBackgroundAction.mockResolvedValue({ success: false, error: 'saveError' }),
|
|
],
|
|
['Ausnahme', () => updateDashboardBackgroundAction.mockRejectedValue(new Error('offline'))],
|
|
])('Speichern schlaegt fehl (%s): der vorige Wert wird zurueckgesetzt', async (_label, arrange) => {
|
|
arrange();
|
|
useAuthStore.setState({
|
|
user: makeUser({ dashboardBackground: { kind: 'preset', id: 'dunes' } }),
|
|
});
|
|
const { result } = await renderBackgroundHook();
|
|
|
|
act(() => {
|
|
result.current.choose({ kind: 'preset', id: 'mist' });
|
|
});
|
|
|
|
await waitFor(() => expect(result.current.background).toEqual({ kind: 'preset', id: 'dunes' }));
|
|
expect(useAuthStore.getState().user?.dashboardBackground).toEqual({
|
|
kind: 'preset',
|
|
id: 'dunes',
|
|
});
|
|
});
|
|
|
|
it('Server-Wert null und alte localStorage-Wahl: wird genau einmal gespeichert, Schluessel entfernt', async () => {
|
|
window.localStorage.setItem(LEGACY_KEY, JSON.stringify({ kind: 'preset', id: 'pebble' }));
|
|
useAuthStore.setState({ user: makeUser({ dashboardBackground: null }) });
|
|
|
|
const { result, rerender } = await renderBackgroundHook();
|
|
|
|
await waitFor(() => expect(updateDashboardBackgroundAction).toHaveBeenCalledTimes(1));
|
|
expect(updateDashboardBackgroundAction).toHaveBeenCalledWith({ kind: 'preset', id: 'pebble' });
|
|
expect(result.current.background).toEqual({ kind: 'preset', id: 'pebble' });
|
|
expect(window.localStorage.getItem(LEGACY_KEY)).toBeNull();
|
|
|
|
// erneutes Rendern und ein neues Benutzerobjekt desselben Benutzers loesen nichts mehr aus
|
|
rerender();
|
|
act(() => useAuthStore.setState({ user: makeUser({ dashboardBackground: null }) }));
|
|
rerender();
|
|
expect(updateDashboardBackgroundAction).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('Server-Wert gesetzt: keine Uebernahme, der alte Schluessel wird nur aufgeraeumt', async () => {
|
|
window.localStorage.setItem(LEGACY_KEY, JSON.stringify({ kind: 'preset', id: 'pebble' }));
|
|
useAuthStore.setState({ user: makeUser({ dashboardBackground: { kind: 'none' } }) });
|
|
|
|
const { result } = await renderBackgroundHook();
|
|
|
|
await waitFor(() => expect(window.localStorage.getItem(LEGACY_KEY)).toBeNull());
|
|
expect(updateDashboardBackgroundAction).not.toHaveBeenCalled();
|
|
expect(result.current.background).toEqual({ kind: 'none' });
|
|
});
|
|
|
|
it('Server-Wert null ohne alte Wahl: nichts wird gespeichert', async () => {
|
|
useAuthStore.setState({ user: makeUser({ dashboardBackground: null }) });
|
|
|
|
const { result } = await renderBackgroundHook();
|
|
|
|
expect(result.current.background).toEqual({ kind: 'none' });
|
|
expect(updateDashboardBackgroundAction).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('useDashboardBackground — Rueckfall nur fuer den eigenen Wert (quick-260930)', () => {
|
|
it('scheitert der erste von zwei schnellen Klicks, bleibt die zweite Wahl stehen', async () => {
|
|
let rejectFirst: (e: Error) => void = () => {};
|
|
updateDashboardBackgroundAction
|
|
.mockImplementationOnce(
|
|
() =>
|
|
new Promise((_resolve, reject) => {
|
|
rejectFirst = reject;
|
|
}),
|
|
)
|
|
.mockResolvedValueOnce({ success: true });
|
|
useAuthStore.setState({ user: makeUser({ dashboardBackground: { kind: 'none' } }) });
|
|
const { result } = await renderBackgroundHook();
|
|
|
|
act(() => result.current.choose({ kind: 'preset', id: 'mist' }));
|
|
act(() => result.current.choose({ kind: 'preset', id: 'dunes' }));
|
|
await act(async () => {
|
|
rejectFirst(new Error('offline'));
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(result.current.background).toEqual({ kind: 'preset', id: 'dunes' });
|
|
expect(useAuthStore.getState().user?.dashboardBackground).toEqual({
|
|
kind: 'preset',
|
|
id: 'dunes',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('useDashboardBackground — Bild nicht mehr vorhanden (quick-260930)', () => {
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it('laesst sich das gewaehlte Bild nicht laden, gilt die Wahl als „kein Hintergrund“ (ohne zu speichern)', async () => {
|
|
class FailingImage {
|
|
onerror: (() => void) | null = null;
|
|
set src(_value: string) {
|
|
setTimeout(() => this.onerror?.(), 0);
|
|
}
|
|
}
|
|
vi.stubGlobal('Image', FailingImage);
|
|
useAuthStore.setState({
|
|
user: makeUser({ dashboardBackground: { kind: 'image', imageId: IMAGE_ID } }),
|
|
});
|
|
|
|
const { result } = await renderBackgroundHook();
|
|
|
|
await waitFor(() => expect(result.current.background).toEqual({ kind: 'none' }));
|
|
expect(updateDashboardBackgroundAction).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('ein ladbares Bild bleibt gewaehlt', async () => {
|
|
class LoadingImage {
|
|
onerror: (() => void) | null = null;
|
|
onload: (() => void) | null = null;
|
|
set src(_value: string) {
|
|
setTimeout(() => this.onload?.(), 0);
|
|
}
|
|
}
|
|
vi.stubGlobal('Image', LoadingImage);
|
|
useAuthStore.setState({
|
|
user: makeUser({ dashboardBackground: { kind: 'image', imageId: IMAGE_ID } }),
|
|
});
|
|
|
|
const { result } = await renderBackgroundHook();
|
|
await act(async () => {
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
});
|
|
|
|
expect(result.current.background).toEqual({ kind: 'image', imageId: IMAGE_ID });
|
|
});
|
|
});
|
|
|
|
describe('BackgroundPicker (quick-260930)', () => {
|
|
async function renderPicker(value: import('@/lib/dashboard-background').DashboardBackground) {
|
|
const { BackgroundPicker } = await import('./dashboard-background');
|
|
const onChange = vi.fn();
|
|
render(<BackgroundPicker value={value} onChange={onChange} />);
|
|
return { onChange, trigger: screen.getByRole('button', { name: 'button' }) };
|
|
}
|
|
|
|
beforeEach(() => {
|
|
fetchDashboardImages.mockReset();
|
|
fetchDashboardImages.mockResolvedValue([]);
|
|
});
|
|
|
|
it('Oeffnen setzt den Fokus auf die erste Auswahl, Escape schliesst und gibt ihn an den Knopf zurueck', async () => {
|
|
const { trigger } = await renderPicker({ kind: 'none' });
|
|
trigger.focus();
|
|
fireEvent.click(trigger);
|
|
|
|
const dialog = await screen.findByRole('dialog');
|
|
const first = screen.getByRole('button', { name: 'none' });
|
|
expect(dialog).toContainElement(first);
|
|
expect(first).toHaveFocus();
|
|
|
|
fireEvent.keyDown(document, { key: 'Escape' });
|
|
expect(screen.queryByRole('dialog')).toBeNull();
|
|
await waitFor(() => expect(trigger).toHaveFocus());
|
|
});
|
|
|
|
it('Klick nach draussen schliesst und gibt den Fokus an den Knopf zurueck', async () => {
|
|
const { trigger } = await renderPicker({ kind: 'none' });
|
|
fireEvent.click(trigger);
|
|
await screen.findByRole('dialog');
|
|
|
|
fireEvent.mouseDown(document.body);
|
|
expect(screen.queryByRole('dialog')).toBeNull();
|
|
await waitFor(() => expect(trigger).toHaveFocus());
|
|
});
|
|
|
|
it('Tab bleibt im Fenster: vom letzten Knopf zum ersten, Umschalt+Tab vom ersten zum letzten', async () => {
|
|
const { trigger } = await renderPicker({ kind: 'none' });
|
|
fireEvent.click(trigger);
|
|
const dialog = await screen.findByRole('dialog');
|
|
const buttons = Array.from(dialog.querySelectorAll('button'));
|
|
const first = buttons[0];
|
|
const last = buttons[buttons.length - 1];
|
|
|
|
last.focus();
|
|
fireEvent.keyDown(document, { key: 'Tab' });
|
|
expect(first).toHaveFocus();
|
|
|
|
fireEvent.keyDown(document, { key: 'Tab', shiftKey: true });
|
|
expect(last).toHaveFocus();
|
|
});
|
|
|
|
it('gewaehltes Bild fehlt in der Bilderliste -> „Keiner“ ist markiert', async () => {
|
|
fetchDashboardImages.mockResolvedValue([
|
|
{ id: 'anderes', originalName: 'urlaub.jpg', mimeType: 'image/jpeg', size: 1, createdAt: '' },
|
|
]);
|
|
const { trigger } = await renderPicker({ kind: 'image', imageId: IMAGE_ID });
|
|
fireEvent.click(trigger);
|
|
|
|
await screen.findByRole('button', { name: 'urlaub.jpg' });
|
|
expect(screen.getByRole('button', { name: 'none' })).toHaveAttribute('aria-pressed', 'true');
|
|
expect(screen.getByRole('button', { name: 'urlaub.jpg' })).toHaveAttribute(
|
|
'aria-pressed',
|
|
'false',
|
|
);
|
|
});
|
|
});
|