fix(dashboard): Rasterversion schuetzen, Kalender-Mindestbreite, Breiten je Breakpoint, Hintergrund

- 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>
This commit is contained in:
2026-09-30 03:20:16 +02:00
parent 071082983b
commit 12214a948e
21 changed files with 1194 additions and 205 deletions
@@ -1,4 +1,4 @@
import { act, renderHook, waitFor } from '@testing-library/react';
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';
@@ -13,6 +13,23 @@ 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 {
@@ -141,3 +158,147 @@ describe('useDashboardBackground (quick-260928-ujj)', () => {
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',
);
});
});