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,92 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
dashboardImageSrc,
deleteDashboardImage,
fetchDashboardImages,
uploadDashboardImage,
} from './dashboard-images-api';
/**
* dashboard-images-api.test — NEU (quick-260921-pi9). Die vier Aufrufe des
* Bilderrahmen-Widgets gegen /dashboard/images: FormData-Feld `image` ohne
* eigenen Content-Type, Cookie immer mit, 413 und 400 mit deutschen
* Meldungen, Proxy-Pfad fuer <img src>.
*/
const { mockFetch } = vi.hoisted(() => ({ mockFetch: vi.fn() }));
beforeEach(() => {
mockFetch.mockReset();
vi.stubGlobal('fetch', mockFetch);
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe('dashboard-images-api (quick-260921-pi9)', () => {
it('Test 1: uploadDashboardImage sendet POST mit FormData-Feld image, credentials include, ohne Content-Type von Hand', async () => {
mockFetch.mockResolvedValue(
new Response(JSON.stringify({ id: 'img-1', originalName: 'a.png', mimeType: 'image/png', size: 3 }), {
status: 201,
}),
);
const file = new File([new Uint8Array([1, 2, 3])], 'a.png', { type: 'image/png' });
const result = await uploadDashboardImage(file);
expect(result.id).toBe('img-1');
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(String(url).endsWith('/dashboard/images')).toBe(true);
expect(init.method).toBe('POST');
expect(init.credentials).toBe('include');
expect(init.headers).toBeUndefined();
const body = init.body as FormData;
expect(body.get('image')).toBeInstanceOf(File);
expect((body.get('image') as File).name).toBe('a.png');
});
it('Test 2: Status 413 -> deutsche Meldung "zu gross"', async () => {
mockFetch.mockResolvedValue(new Response('{"message":"File too large"}', { status: 413 }));
await expect(uploadDashboardImage(new File([1], 'x.png'))).rejects.toThrow(
'Die Datei ist zu groß – erlaubt sind höchstens 5 MB.',
);
});
it('Test 3: Status 400 mit { message } -> genau diese Meldung; sonst allgemeine Meldung', async () => {
mockFetch.mockResolvedValue(
new Response(JSON.stringify({ message: 'Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.' }), {
status: 400,
}),
);
await expect(uploadDashboardImage(new File([1], 'x.png'))).rejects.toThrow(
'Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.',
);
mockFetch.mockResolvedValue(new Response('kaputt', { status: 500 }));
await expect(uploadDashboardImage(new File([1], 'x.png'))).rejects.toThrow(
'Das Bild konnte nicht hochgeladen werden.',
);
});
it('Test 4: fetchDashboardImages (GET) und deleteDashboardImage (DELETE, Kennung kodiert) mit Cookie', async () => {
mockFetch.mockResolvedValue(new Response('[]', { status: 200 }));
await expect(fetchDashboardImages()).resolves.toEqual([]);
const [listUrl, listInit] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(String(listUrl).endsWith('/dashboard/images')).toBe(true);
expect(listInit.credentials).toBe('include');
mockFetch.mockResolvedValue(new Response('{"id":"a b"}', { status: 200 }));
await deleteDashboardImage('a b');
const [delUrl, delInit] = mockFetch.mock.calls[1] as [string, RequestInit];
expect(String(delUrl).endsWith('/dashboard/images/a%20b')).toBe(true);
expect(delInit.method).toBe('DELETE');
expect(delInit.credentials).toBe('include');
mockFetch.mockResolvedValue(new Response('', { status: 404 }));
await expect(deleteDashboardImage('weg')).rejects.toThrow();
});
it('Test 5: dashboardImageSrc kodiert die Kennung im Proxy-Pfad', () => {
expect(dashboardImageSrc('a b')).toBe('/api-proxy/dashboard/images/a%20b');
expect(dashboardImageSrc('img-1')).toBe('/api-proxy/dashboard/images/img-1');
});
});