8bf3601a18
Befunde aus dem Browser-Rundgang am 21.09.2026:
- Die Grossansicht lag in einem `react-grid-item` mit CSS-`transform`; ein
transformierter Vorfahr wird fuer `position: fixed` zum Bezugsrahmen, der
Dialog war deshalb auf die Kachelflaeche (531x216) beschraenkt statt den
Viewport zu fuellen. Jetzt per `createPortal` in `document.body`, wie der
Kalender-Tooltip.
- Wechselintervall "1 Minuten" -> ICU-Plural (`one {# Minute}`), de + en; der
Formular-Test nutzt dafuer den echten `createTranslator` von next-intl auf
der echten de.json statt eines `{n}`-Ersatzes.
- Standardgroesse 8x8 (216 px hoch) war zu flach fuer ein Foto -> 8x12 wie die
Kalender-Vorgabe.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
260 lines
11 KiB
TypeScript
260 lines
11 KiB
TypeScript
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import de from '@/messages/de.json';
|
|
|
|
/**
|
|
* picture-frame-config-form.test — NEU (quick-260921-pi9). Texte aus der
|
|
* echten de.json (Muster widget-settings-panel.test.tsx): Auswahlfelder,
|
|
* Eintragsliste mit Vorschau/Unterschrift/Pfeilen/Entfernen, Upload-Kette,
|
|
* https-Pruefung, Grenze 30.
|
|
*/
|
|
vi.mock('next-intl', async (importOriginal) => {
|
|
// Echter ICU-Uebersetzer von next-intl auf der echten de.json: die
|
|
// Intervall-Beschriftung nutzt `{n, plural, one {…} other {…}}`, ein
|
|
// handgeschriebener `{n}`-Ersatz wuerde das Muster nicht verstehen.
|
|
const actual = await importOriginal<typeof import('next-intl')>();
|
|
const messages = (await import('@/messages/de.json')).default;
|
|
return {
|
|
useTranslations: (ns?: string) => actual.createTranslator({ locale: 'de', messages, namespace: ns }),
|
|
};
|
|
});
|
|
|
|
const { uploadMock, deleteMock } = vi.hoisted(() => ({
|
|
uploadMock: vi.fn(),
|
|
deleteMock: vi.fn(),
|
|
}));
|
|
vi.mock('@/lib/dashboard-images-api', () => ({
|
|
uploadDashboardImage: uploadMock,
|
|
deleteDashboardImage: deleteMock,
|
|
dashboardImageSrc: (id: string) => `/api-proxy/dashboard/images/${encodeURIComponent(id)}`,
|
|
}));
|
|
|
|
import { PictureFrameConfigForm } from './picture-frame-config-form';
|
|
|
|
const texts = (de as { widgets: { pictureFrame: Record<string, string> } }).widgets.pictureFrame;
|
|
const URL_A = 'https://bilder.example/a.jpg';
|
|
|
|
function renderForm(config: Record<string, unknown>) {
|
|
const onChange = vi.fn();
|
|
render(<PictureFrameConfigForm config={config} onChange={onChange} />);
|
|
return { onChange };
|
|
}
|
|
|
|
beforeEach(() => {
|
|
uploadMock.mockReset();
|
|
deleteMock.mockReset();
|
|
deleteMock.mockResolvedValue(undefined);
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
});
|
|
|
|
describe('PictureFrameConfigForm (quick-260921-pi9)', () => {
|
|
it('Test 1: drei Auswahlfelder mit Vorgaben; jede Aenderung ruft onChange mit genau dem einen Feld', () => {
|
|
const { onChange } = renderForm({});
|
|
const fit = screen.getByLabelText(texts.fitLabel) as HTMLSelectElement;
|
|
const interval = screen.getByLabelText(texts.intervalLabel) as HTMLSelectElement;
|
|
const order = screen.getByLabelText(texts.orderLabel) as HTMLSelectElement;
|
|
expect(fit.value).toBe('contain');
|
|
expect(interval.value).toBe('30');
|
|
expect(order.value).toBe('sequence');
|
|
expect(Array.from(interval.options).map((o) => o.value)).toEqual([
|
|
'0', '5', '10', '15', '30', '60', '120', '300', '600', '1800', '3600',
|
|
]);
|
|
expect(Array.from(interval.options).map((o) => o.textContent)).toContain('Kein Wechsel');
|
|
expect(Array.from(interval.options).map((o) => o.textContent)).toContain('5 Sekunden');
|
|
expect(Array.from(interval.options).map((o) => o.textContent)).toContain('30 Minuten');
|
|
expect(Array.from(interval.options).map((o) => o.textContent)).toContain('1 Minute');
|
|
|
|
fireEvent.change(fit, { target: { value: 'cover' } });
|
|
expect(onChange).toHaveBeenLastCalledWith({ fit: 'cover' });
|
|
fireEvent.change(interval, { target: { value: '120' } });
|
|
expect(onChange).toHaveBeenLastCalledWith({ intervalSeconds: 120 });
|
|
fireEvent.change(order, { target: { value: 'random' } });
|
|
expect(onChange).toHaveBeenLastCalledWith({ order: 'random' });
|
|
expect(onChange).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it('Test 2: Liste zeigt je Eintrag Vorschau (no-referrer, Upload ueber Proxy-Pfad); kaputte Vorschau wird zu "Bild nicht verfügbar"', () => {
|
|
renderForm({
|
|
images: [
|
|
{ kind: 'url', url: URL_A },
|
|
{ kind: 'upload', imageId: 'img-1' },
|
|
],
|
|
});
|
|
const previews = screen.getAllByTestId('picture-frame-preview') as HTMLImageElement[];
|
|
expect(previews).toHaveLength(2);
|
|
expect(previews[0].getAttribute('src')).toBe(URL_A);
|
|
expect(previews[0].getAttribute('referrerpolicy')).toBe('no-referrer');
|
|
expect(previews[1].getAttribute('src')).toBe('/api-proxy/dashboard/images/img-1');
|
|
|
|
fireEvent.error(previews[0]);
|
|
expect(screen.getAllByTestId('picture-frame-preview')).toHaveLength(1);
|
|
expect(screen.getByText(texts.unavailable)).toBeInTheDocument();
|
|
});
|
|
|
|
it('Test 3: Unterschrift — Entwurf im Feld, Uebernahme bei Blur/Enter mit dem GANZEN images-Array; unveraendert -> kein Aufruf', () => {
|
|
const { onChange } = renderForm({
|
|
images: [
|
|
{ kind: 'url', url: URL_A, caption: 'Alt' },
|
|
{ kind: 'upload', imageId: 'img-1' },
|
|
],
|
|
});
|
|
const inputs = screen.getAllByLabelText(texts.captionPlaceholder) as HTMLInputElement[];
|
|
expect(inputs[0].value).toBe('Alt');
|
|
expect(inputs[1].value).toBe('');
|
|
|
|
fireEvent.change(inputs[1], { target: { value: 'Neu' } });
|
|
expect(onChange).not.toHaveBeenCalled();
|
|
fireEvent.keyDown(inputs[1], { key: 'Enter' });
|
|
expect(onChange).toHaveBeenCalledWith({
|
|
images: [
|
|
{ kind: 'url', url: URL_A, caption: 'Alt' },
|
|
{ kind: 'upload', imageId: 'img-1', caption: 'Neu' },
|
|
],
|
|
});
|
|
|
|
// Leer machen entfernt die Unterschrift, Blur uebernimmt
|
|
fireEvent.change(inputs[0], { target: { value: ' ' } });
|
|
fireEvent.blur(inputs[0]);
|
|
expect(onChange).toHaveBeenLastCalledWith({
|
|
images: [
|
|
{ kind: 'url', url: URL_A },
|
|
{ kind: 'upload', imageId: 'img-1' },
|
|
],
|
|
});
|
|
|
|
// Unveraendert: kein weiterer Aufruf
|
|
const calls = onChange.mock.calls.length;
|
|
fireEvent.blur(inputs[1]);
|
|
expect(onChange).toHaveBeenCalledTimes(calls);
|
|
});
|
|
|
|
it('Test 4: Pfeile — oberster ohne "hoch", unterster ohne "runter"; Bewegen sendet das ganze Array in neuer Reihenfolge', () => {
|
|
const { onChange } = renderForm({
|
|
images: [
|
|
{ kind: 'url', url: URL_A },
|
|
{ kind: 'upload', imageId: 'img-1' },
|
|
{ kind: 'upload', imageId: 'img-2' },
|
|
],
|
|
});
|
|
const ups = screen.getAllByRole('button', { name: texts.moveUpButton }) as HTMLButtonElement[];
|
|
const downs = screen.getAllByRole('button', { name: texts.moveDownButton }) as HTMLButtonElement[];
|
|
expect(ups[0].disabled).toBe(true);
|
|
expect(ups[1].disabled).toBe(false);
|
|
expect(downs[2].disabled).toBe(true);
|
|
expect(downs[1].disabled).toBe(false);
|
|
|
|
fireEvent.click(downs[0]);
|
|
expect(onChange).toHaveBeenLastCalledWith({
|
|
images: [
|
|
{ kind: 'upload', imageId: 'img-1' },
|
|
{ kind: 'url', url: URL_A },
|
|
{ kind: 'upload', imageId: 'img-2' },
|
|
],
|
|
});
|
|
fireEvent.click(ups[2]);
|
|
expect(onChange).toHaveBeenLastCalledWith({
|
|
images: [
|
|
{ kind: 'url', url: URL_A },
|
|
{ kind: 'upload', imageId: 'img-2' },
|
|
{ kind: 'upload', imageId: 'img-1' },
|
|
],
|
|
});
|
|
});
|
|
|
|
it('Test 5: Entfernen eines Upload-Eintrags ruft deleteDashboardImage (Fehler verschluckt) UND onChange mit dem verkuerzten Array; URL-Eintrag ohne Serveraufruf', async () => {
|
|
deleteMock.mockRejectedValue(new Error('weg'));
|
|
const { onChange } = renderForm({
|
|
images: [
|
|
{ kind: 'upload', imageId: 'img-1' },
|
|
{ kind: 'url', url: URL_A },
|
|
],
|
|
});
|
|
const removes = screen.getAllByRole('button', { name: texts.removeButton });
|
|
|
|
fireEvent.click(removes[0]);
|
|
expect(deleteMock).toHaveBeenCalledWith('img-1');
|
|
expect(onChange).toHaveBeenLastCalledWith({ images: [{ kind: 'url', url: URL_A }] });
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
});
|
|
|
|
fireEvent.click(removes[1]);
|
|
expect(deleteMock).toHaveBeenCalledTimes(1);
|
|
expect(onChange).toHaveBeenLastCalledWith({ images: [{ kind: 'upload', imageId: 'img-1' }] });
|
|
});
|
|
|
|
it('Test 6: Webadresse — http:// zeigt role=alert und ruft onChange nicht; https ruft onChange mit angehaengtem URL-Eintrag', () => {
|
|
const { onChange } = renderForm({ images: [{ kind: 'upload', imageId: 'img-1' }] });
|
|
const input = screen.getByLabelText(texts.urlAddButton) as HTMLInputElement;
|
|
const button = screen.getByRole('button', { name: texts.urlAddButton });
|
|
|
|
fireEvent.change(input, { target: { value: 'http://bilder.example/a.jpg' } });
|
|
fireEvent.click(button);
|
|
expect(screen.getByRole('alert')).toHaveTextContent(texts.urlInvalid);
|
|
expect(onChange).not.toHaveBeenCalled();
|
|
|
|
fireEvent.change(input, { target: { value: URL_A } });
|
|
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
|
fireEvent.keyDown(input, { key: 'Enter' });
|
|
expect(onChange).toHaveBeenCalledWith({
|
|
images: [
|
|
{ kind: 'upload', imageId: 'img-1' },
|
|
{ kind: 'url', url: URL_A },
|
|
],
|
|
});
|
|
expect(input.value).toBe('');
|
|
});
|
|
|
|
it('Test 7: Datei waehlen ruft uploadDashboardImage(file) und danach onChange mit neuem Upload-Eintrag (Kennung aus der Antwort)', async () => {
|
|
uploadMock.mockResolvedValue({ id: 'neu-42', originalName: 'a.png', mimeType: 'image/png', size: 3 });
|
|
const { onChange } = renderForm({ images: [{ kind: 'url', url: URL_A }] });
|
|
const file = new File([new Uint8Array([1, 2, 3])], 'a.png', { type: 'image/png' });
|
|
const input = screen.getByTestId('picture-frame-file-input') as HTMLInputElement;
|
|
|
|
await act(async () => {
|
|
fireEvent.change(input, { target: { files: [file] } });
|
|
});
|
|
|
|
expect(uploadMock).toHaveBeenCalledWith(file);
|
|
await waitFor(() =>
|
|
expect(onChange).toHaveBeenCalledWith({
|
|
images: [
|
|
{ kind: 'url', url: URL_A },
|
|
{ kind: 'upload', imageId: 'neu-42' },
|
|
],
|
|
}),
|
|
);
|
|
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('Test 8: wirft der Upload, erscheint seine Meldung als role=alert und onChange bleibt aus', async () => {
|
|
uploadMock.mockRejectedValue(new Error('Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.'));
|
|
const { onChange } = renderForm({});
|
|
const input = screen.getByTestId('picture-frame-file-input') as HTMLInputElement;
|
|
|
|
await act(async () => {
|
|
fireEvent.change(input, { target: { files: [new File([1], 'x.png')] } });
|
|
});
|
|
|
|
await waitFor(() =>
|
|
expect(screen.getByRole('alert')).toHaveTextContent(
|
|
'Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.',
|
|
),
|
|
);
|
|
expect(onChange).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('Test 9: bei 30 Eintraegen ist "Bild hochladen" deaktiviert, der Hinweis limitReached steht, Webadresse ebenfalls gesperrt', () => {
|
|
const images = Array.from({ length: 30 }, (_, i) => ({ kind: 'upload', imageId: `img-${i}` }));
|
|
renderForm({ images });
|
|
const upload = screen.getByRole('button', { name: texts.uploadButton }) as HTMLButtonElement;
|
|
expect(upload.disabled).toBe(true);
|
|
expect(screen.getByTestId('picture-frame-limit')).toHaveTextContent(texts.limitReached);
|
|
expect((screen.getByRole('button', { name: texts.urlAddButton }) as HTMLButtonElement).disabled).toBe(true);
|
|
expect(screen.getAllByTestId('picture-frame-entry')).toHaveLength(30);
|
|
});
|
|
});
|