feat(quick-260921-pi9): Bilderrahmen-Widget - Diashow mit Grossansicht, Bildverwaltung in den Einstellungen

- picture-frame-config.ts: Eintragstyp als Vereinigung (upload | url) in EINER
  geordneten Liste, resolvePictureFrameConfig laesst alles ausser https weg
  (T-PI9-07), Intervall 0 oder 5..3600 s, pickNextIndex (Zufall nie dasselbe)
- dashboard-images-api.ts: Upload als FormData-Feld image ohne eigenen
  Content-Type, 413 -> deutsche Meldung, Proxy-Pfad fuer <img src>
- PictureFrameWidget: Leerzustand, <img referrerPolicy=no-referrer> (Browser
  laedt Fremdbilder, Server nie), object-contain/cover, Unterschrift-Streifen,
  Wechsel per Timer mit Raeumung, kaputte Bilder verlassen den Umlauf,
  Grossansicht nur ausserhalb des Bearbeitungsmodus mit Fokus-Rueckgabe und
  Pause des Wechsels; im Bearbeitungsmodus kein Knopf (Karte bleibt Griff)
- PictureFrameConfigForm im WidgetSettingsPanel: Ausschnitt, Intervall,
  Reihenfolge, Liste mit Vorschau/Unterschrift/Pfeilen/Entfernen (Upload wird
  auch serverseitig geloescht), Datei hochladen, https-Adresse hinzufuegen
- Registry (4x4 min, 8x8 Vorgabe), Katalog, Seite, Uebersetzungen de/en
  (widgets.pictureFrame, 28 Schluessel), bestehende Tests auf acht Typen

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-21 18:56:02 +02:00
parent 737974b653
commit c080580459
18 changed files with 1688 additions and 4 deletions
@@ -0,0 +1,263 @@
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 () => {
const messages = (await import('@/messages/de.json')).default as Record<string, unknown>;
const lookup = (path: string): string | undefined =>
path.split('.').reduce<unknown>((o, k) => (o && typeof o === 'object' ? (o as Record<string, unknown>)[k] : undefined), messages) as
| string
| undefined;
return {
useTranslations:
(ns?: string) =>
(key: string, values?: Record<string, unknown>) => {
const raw = lookup(ns ? `${ns}.${key}` : key) ?? key;
return values ? raw.replace(/\{(\w+)\}/g, (_: string, n: string) => String(values[n] ?? '')) : raw;
},
};
});
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');
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);
});
});