test(260925-bow): Auswahl, Server-Aktionen, Fenster und Host des Was-ist-neu-Fensters

- selectReleaseNotice: Bereich, Deckel 3, null, unparsebar, nur Neu/Geaendert/Behoben
- Server-Aktionen: Cookie, Antwortform, Fehler still
- Fenster: role=dialog, Fokusfalle, alle Schliesswege genau einmal
- Host: merkt erst beim Schliessen, nicht auf /change-password, StrictMode

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-25 08:48:25 +02:00
parent 59db32a01f
commit cf7784e409
4 changed files with 760 additions and 0 deletions
@@ -0,0 +1,217 @@
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
import { NextIntlClientProvider } from 'next-intl';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ReleaseNotice } from '@/lib/release-notes';
import de from '@/messages/de.json';
/**
* release-notice-dialog.test — "Was ist neu"-Fenster (quick-260925-bow, D-07).
*
* Echte deutsche Texte ueber NextIntlClientProvider (prueft auch die
* ICU-Mehrzahl von `moreVersions`). Der Markdown-Renderer wird wie im Test
* der Seite /changelog durch ein <pre> ersetzt; `next/link` durch ein
* schlichtes <a> (Vorbild ServerCard.test.tsx).
*/
vi.mock('@uiw/react-md-editor', () => ({
default: {
Markdown: ({ source }: { source?: string }) => <pre data-testid="md">{source}</pre>,
},
}));
vi.mock('next-themes', () => ({
useTheme: () => ({ resolvedTheme: 'light' }),
}));
vi.mock('next/link', () => ({
default: ({
href,
children,
onClick,
className,
}: {
href: string;
children: React.ReactNode;
onClick?: () => void;
className?: string;
}) => (
<a
href={href}
className={className}
onClick={(event) => {
event.preventDefault();
onClick?.();
}}
>
{children}
</a>
),
}));
import { ReleaseNoticeDialog } from './release-notice-dialog';
const ONE: ReleaseNotice = {
currentRelease: '1.4.0',
versions: [
{
version: '1.4.0',
sections: [
{ kind: 'new', markdown: '- Funktion N140' },
{ kind: 'changed', markdown: '- Aenderung G140' },
{ kind: 'fixed', markdown: '- Fehler F140' },
],
},
],
omittedCount: 0,
};
const THREE: ReleaseNotice = {
currentRelease: '1.4.0',
versions: [
{ version: '1.4.0', sections: [{ kind: 'new', markdown: '- N140' }] },
{ version: '1.3.1', sections: [{ kind: 'fixed', markdown: '- F131' }] },
{ version: '1.3.0', sections: [{ kind: 'changed', markdown: '- G130' }] },
],
omittedCount: 2,
};
function renderDialog(notice: ReleaseNotice, onClose = vi.fn()) {
render(
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
<ReleaseNoticeDialog notice={notice} onClose={onClose} />
</NextIntlClientProvider>,
);
return onClose;
}
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe('ReleaseNoticeDialog (quick-260925-bow)', () => {
it('Titel „Neu in Version 1.4.0“, role=dialog mit aria-modal und aria-labelledby auf die Ueberschrift', () => {
renderDialog(ONE);
const dialog = screen.getByRole('dialog');
expect(dialog).toHaveAttribute('aria-modal', 'true');
const heading = screen.getByRole('heading', { level: 2, name: 'Neu in Version 1.4.0' });
expect(dialog.getAttribute('aria-labelledby')).toBe(heading.id);
expect(heading.id).not.toBe('');
});
it('Anfangsfokus liegt auf der Ueberschrift', () => {
renderDialog(ONE);
expect(document.activeElement).toBe(screen.getByRole('heading', { level: 2 }));
});
it('Gruppenueberschriften Neu, Verbessert, Behoben mit den Eintraegen; keine Versionsunterueberschrift bei einer Version', () => {
renderDialog(ONE);
const groups = screen.getAllByRole('heading', { level: 3 }).map((h) => h.textContent);
expect(groups).toEqual(['Neu', 'Verbessert', 'Behoben']);
expect(screen.queryByText('Version 1.4.0')).toBeNull();
const md = screen.getAllByTestId('md').map((el) => el.textContent);
expect(md).toEqual(['- Funktion N140', '- Aenderung G140', '- Fehler F140']);
});
it('kein Satz ueber weitere Versionen bei omittedCount 0', () => {
renderDialog(ONE);
expect(screen.queryByText(/Dazu kommen Änderungen/)).toBeNull();
});
it('mehrere Versionen: Versionsunterueberschriften und Satz mit der Zahl weiterer Versionen', () => {
renderDialog(THREE);
const versions = screen.getAllByRole('heading', { level: 3 }).map((h) => h.textContent);
expect(versions).toEqual(['Version 1.4.0', 'Version 1.3.1', 'Version 1.3.0']);
const groups = screen.getAllByRole('heading', { level: 4 }).map((h) => h.textContent);
expect(groups).toEqual(['Neu', 'Behoben', 'Verbessert']);
expect(screen.getByText('Dazu kommen Änderungen aus 2 älteren Versionen.')).toBeInTheDocument();
});
it('Einzahl im Satz ueber weitere Versionen', () => {
renderDialog({ ...THREE, omittedCount: 1 });
expect(screen.getByText('Dazu kommen Änderungen aus 1 älteren Version.')).toBeInTheDocument();
});
it('Link „Alle Änderungen ansehen“ auf /changelog ist immer vorhanden', () => {
renderDialog(ONE);
const link = screen.getByRole('link', { name: 'Alle Änderungen ansehen' });
expect(link).toHaveAttribute('href', '/changelog');
});
it('„Verstanden“ ruft onClose genau einmal', () => {
const onClose = renderDialog(ONE);
fireEvent.click(screen.getByRole('button', { name: 'Verstanden' }));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('das Schließen-Kreuz ruft onClose genau einmal', () => {
const onClose = renderDialog(ONE);
const dialog = screen.getByRole('dialog');
fireEvent.click(within(dialog).getByRole('button', { name: 'Schließen' }));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('Escape ruft onClose genau einmal', () => {
const onClose = renderDialog(ONE);
fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose).toHaveBeenCalledTimes(1);
});
it('der Hintergrund ist eine benannte Schaltflaeche und ruft onClose genau einmal', () => {
const onClose = renderDialog(ONE);
fireEvent.click(screen.getByRole('button', { name: 'Fenster schließen' }));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('der Link zu /changelog ruft onClose genau einmal', () => {
const onClose = renderDialog(ONE);
fireEvent.click(screen.getByRole('link', { name: 'Alle Änderungen ansehen' }));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('Fokusfalle: Tab vom letzten Element springt zum ersten, Umschalt+Tab vom ersten zum letzten', () => {
renderDialog(ONE);
const dialog = screen.getByRole('dialog');
const close = within(dialog).getByRole('button', { name: 'Schließen' });
const confirm = screen.getByRole('button', { name: 'Verstanden' });
confirm.focus();
fireEvent.keyDown(document, { key: 'Tab' });
expect(document.activeElement).toBe(close);
fireEvent.keyDown(document, { key: 'Tab', shiftKey: true });
expect(document.activeElement).toBe(confirm);
});
it('Tab mit Fokus auf der Ueberschrift springt auf das erste Element im Fenster', () => {
renderDialog(ONE);
const dialog = screen.getByRole('dialog');
expect(document.activeElement).toBe(screen.getByRole('heading', { level: 2 }));
fireEvent.keyDown(document, { key: 'Tab' });
expect(document.activeElement).toBe(within(dialog).getByRole('button', { name: 'Schließen' }));
});
it('Fokus ausserhalb des Fensters (z. B. auf dem Hintergrund) wird per Tab ins Fenster geholt', () => {
renderDialog(ONE);
const backdrop = screen.getByRole('button', { name: 'Fenster schließen' });
const dialog = screen.getByRole('dialog');
backdrop.focus();
fireEvent.keyDown(document, { key: 'Tab' });
expect(dialog.contains(document.activeElement)).toBe(true);
});
it('gibt beim Schliessen den Fokus an das zuvor aktive Element zurueck', () => {
const opener = document.createElement('button');
document.body.appendChild(opener);
opener.focus();
const { unmount } = render(
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
<ReleaseNoticeDialog notice={ONE} onClose={vi.fn()} />
</NextIntlClientProvider>,
);
expect(document.activeElement).not.toBe(opener);
unmount();
expect(document.activeElement).toBe(opener);
opener.remove();
});
});
@@ -0,0 +1,130 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { StrictMode } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ReleaseNotice } from '@/lib/release-notes';
/**
* release-notice-host.test — laedt die Nachricht einmal je Seitenladung im
* Portal-Rahmen und merkt erst beim Schliessen (quick-260925-bow, D-05/D-08).
*
* Server-Aktionen gemockt; das Fenster selbst ist durch eine schlanke
* Attrappe ersetzt (eigene Tests in release-notice-dialog.test.tsx).
* `usePathname` per veraenderbarer Variable (Muster: sidebar.test.tsx).
*/
const { fetchReleaseNotice, markReleaseSeenAction } = vi.hoisted(() => ({
fetchReleaseNotice: vi.fn(),
markReleaseSeenAction: vi.fn(),
}));
vi.mock('@/lib/release-notice-actions', () => ({
fetchReleaseNotice,
markReleaseSeenAction,
}));
let mockPathname = '/';
vi.mock('next/navigation', () => ({
usePathname: () => mockPathname,
}));
vi.mock('./release-notice-dialog', () => ({
ReleaseNoticeDialog: ({ notice, onClose }: { notice: ReleaseNotice; onClose: () => void }) => (
<div role="dialog" aria-label={`Neu in Version ${notice.currentRelease}`}>
<button type="button" onClick={onClose}>
Verstanden
</button>
</div>
),
}));
import { ReleaseNoticeHost } from './release-notice-host';
const NOTICE: ReleaseNotice = {
currentRelease: '1.4.0',
versions: [{ version: '1.4.0', sections: [{ kind: 'new', markdown: '- N' }] }],
omittedCount: 0,
};
beforeEach(() => {
mockPathname = '/';
markReleaseSeenAction.mockResolvedValue({ success: true });
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe('ReleaseNoticeHost (quick-260925-bow)', () => {
it('ohne Nachricht rendert er nichts', async () => {
fetchReleaseNotice.mockResolvedValue(null);
const { container } = render(<ReleaseNoticeHost />);
await waitFor(() => expect(fetchReleaseNotice).toHaveBeenCalledTimes(1));
await act(async () => {});
expect(container).toBeEmptyDOMElement();
expect(screen.queryByRole('dialog')).toBeNull();
});
it('mit Nachricht erscheint das Fenster; beim Oeffnen wird NICHT gemerkt', async () => {
fetchReleaseNotice.mockResolvedValue(NOTICE);
render(<ReleaseNoticeHost />);
expect(await screen.findByRole('dialog', { name: 'Neu in Version 1.4.0' })).toBeInTheDocument();
expect(markReleaseSeenAction).not.toHaveBeenCalled();
});
it('nach dem Schliessen ist das Fenster weg und die Version genau einmal gemerkt', async () => {
fetchReleaseNotice.mockResolvedValue(NOTICE);
render(<ReleaseNoticeHost />);
fireEvent.click(await screen.findByRole('button', { name: 'Verstanden' }));
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
expect(markReleaseSeenAction).toHaveBeenCalledTimes(1);
expect(markReleaseSeenAction).toHaveBeenCalledWith('1.4.0');
});
it('schlaegt das Merken fehl, bleibt das Fenster trotzdem zu (erscheint beim naechsten Laden erneut)', async () => {
fetchReleaseNotice.mockResolvedValue(NOTICE);
markReleaseSeenAction.mockRejectedValue(new Error('down'));
render(<ReleaseNoticeHost />);
fireEvent.click(await screen.findByRole('button', { name: 'Verstanden' }));
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
expect(markReleaseSeenAction).toHaveBeenCalledTimes(1);
});
it('auf /change-password wird nicht abgefragt, nach dem Wechsel auf / genau einmal', async () => {
fetchReleaseNotice.mockResolvedValue(null);
mockPathname = '/change-password';
const { rerender } = render(<ReleaseNoticeHost />);
await act(async () => {});
expect(fetchReleaseNotice).not.toHaveBeenCalled();
mockPathname = '/';
rerender(<ReleaseNoticeHost />);
await waitFor(() => expect(fetchReleaseNotice).toHaveBeenCalledTimes(1));
mockPathname = '/dashboard';
rerender(<ReleaseNoticeHost />);
await act(async () => {});
expect(fetchReleaseNotice).toHaveBeenCalledTimes(1);
});
it('StrictMode-Doppeleffekt fuehrt nicht zu zwei Abrufen', async () => {
fetchReleaseNotice.mockResolvedValue(NOTICE);
render(
<StrictMode>
<ReleaseNoticeHost />
</StrictMode>,
);
expect(await screen.findByRole('dialog')).toBeInTheDocument();
expect(fetchReleaseNotice).toHaveBeenCalledTimes(1);
});
it('ein Fehler beim Abruf bleibt still: kein Fenster', async () => {
fetchReleaseNotice.mockRejectedValue(new Error('down'));
const { container } = render(<ReleaseNoticeHost />);
await waitFor(() => expect(fetchReleaseNotice).toHaveBeenCalledTimes(1));
await act(async () => {});
expect(container).toBeEmptyDOMElement();
});
});