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:
@@ -0,0 +1,222 @@
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// next-intl — Durchreiche t(key) => key (Muster stopwatch-widget.test.tsx)
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
import { PictureFrameWidget } from './picture-frame-widget';
|
||||
|
||||
/**
|
||||
* picture-frame-widget.test — NEU (quick-260921-pi9).
|
||||
*
|
||||
* Kachel: Leerzustand, <img> je Variante (URL direkt mit no-referrer,
|
||||
* Upload ueber den Proxy-Pfad), Ausschnitt-Klassen, Unterschrift-Streifen,
|
||||
* Wechsel per Timer inkl. Raeumung beim Aushaengen, kein Wechsel bei 0,
|
||||
* kaputte Bilder verlassen den Umlauf, Grossansicht (Escape / Hintergrund /
|
||||
* Schliessen-Knopf, Fokus-Rueckgabe, Pause des Wechsels), kein Knopf im
|
||||
* Bearbeitungsmodus.
|
||||
*/
|
||||
const URL_A = 'https://bilder.example/a.jpg';
|
||||
const URL_B = 'https://bilder.example/b.jpg';
|
||||
|
||||
function renderWidget(config: Record<string, unknown>, isEditMode = false) {
|
||||
return render(<PictureFrameWidget instanceId="pf-1" config={config} isEditMode={isEditMode} />);
|
||||
}
|
||||
|
||||
function currentImg(): HTMLImageElement {
|
||||
return screen.getByTestId('picture-frame-image') as HTMLImageElement;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('PictureFrameWidget (quick-260921-pi9)', () => {
|
||||
it('Test 1: leere Konfiguration -> Leerhinweis, kein Bild', () => {
|
||||
renderWidget({});
|
||||
expect(screen.getByText('pictureFrame.empty')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('picture-frame-image')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Test 2: URL-Eintrag -> <img> mit der Adresse, referrerPolicy no-referrer, object-contain, alt = Unterschrift', () => {
|
||||
renderWidget({ images: [{ kind: 'url', url: URL_A, caption: 'Urlaub' }] });
|
||||
const img = currentImg();
|
||||
expect(img.getAttribute('src')).toBe(URL_A);
|
||||
expect(img.getAttribute('referrerpolicy')).toBe('no-referrer');
|
||||
expect(img.className).toContain('object-contain');
|
||||
expect(img.getAttribute('alt')).toBe('Urlaub');
|
||||
expect(screen.getByTestId('picture-frame-caption')).toHaveTextContent('Urlaub');
|
||||
});
|
||||
|
||||
it('Test 3: fit cover -> object-cover; ohne Unterschrift alt="" und kein Streifen', () => {
|
||||
renderWidget({ images: [{ kind: 'url', url: URL_A }], fit: 'cover' });
|
||||
const img = currentImg();
|
||||
expect(img.className).toContain('object-cover');
|
||||
expect(img.getAttribute('alt')).toBe('');
|
||||
expect(screen.queryByTestId('picture-frame-caption')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Test 4: Upload-Eintrag -> src ueber den Proxy-Pfad /api-proxy/dashboard/images/<id>', () => {
|
||||
renderWidget({ images: [{ kind: 'upload', imageId: 'img 1' }] });
|
||||
expect(currentImg().getAttribute('src')).toBe('/api-proxy/dashboard/images/img%201');
|
||||
});
|
||||
|
||||
it('Test 5: Intervall 5 s mit zwei Bildern -> nach 5000 ms das zweite, nach 10000 ms wieder das erste; unmount raeumt den Timer', () => {
|
||||
const { unmount } = renderWidget({
|
||||
images: [
|
||||
{ kind: 'url', url: URL_A },
|
||||
{ kind: 'url', url: URL_B },
|
||||
],
|
||||
intervalSeconds: 5,
|
||||
});
|
||||
expect(currentImg().getAttribute('src')).toBe(URL_A);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
});
|
||||
expect(currentImg().getAttribute('src')).toBe(URL_B);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
});
|
||||
expect(currentImg().getAttribute('src')).toBe(URL_A);
|
||||
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
unmount();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('Test 6: intervalSeconds 0 -> nie ein Wechsel, kein Timer; ein einzelnes Bild -> ebenfalls kein Timer', () => {
|
||||
renderWidget({
|
||||
images: [
|
||||
{ kind: 'url', url: URL_A },
|
||||
{ kind: 'url', url: URL_B },
|
||||
],
|
||||
intervalSeconds: 0,
|
||||
});
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(60_000);
|
||||
});
|
||||
expect(currentImg().getAttribute('src')).toBe(URL_A);
|
||||
cleanup();
|
||||
|
||||
renderWidget({ images: [{ kind: 'url', url: URL_A }], intervalSeconds: 5 });
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('Test 7: Zufall mit drei Bildern -> ueber 30 Wechsel nie dasselbe Bild zweimal hintereinander', () => {
|
||||
renderWidget({
|
||||
images: [
|
||||
{ kind: 'url', url: URL_A },
|
||||
{ kind: 'url', url: URL_B },
|
||||
{ kind: 'url', url: 'https://bilder.example/c.jpg' },
|
||||
],
|
||||
intervalSeconds: 5,
|
||||
order: 'random',
|
||||
});
|
||||
let previous = currentImg().getAttribute('src');
|
||||
for (let i = 0; i < 30; i++) {
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
});
|
||||
const next = currentImg().getAttribute('src');
|
||||
expect(next).not.toBe(previous);
|
||||
previous = next;
|
||||
}
|
||||
});
|
||||
|
||||
it('Test 8: onError nimmt den Eintrag aus dem Umlauf — bei zwei Eintraegen bleibt nur der andere; sind alle kaputt, steht pictureFrame.unavailable', () => {
|
||||
renderWidget({
|
||||
images: [
|
||||
{ kind: 'url', url: URL_A },
|
||||
{ kind: 'url', url: URL_B },
|
||||
],
|
||||
intervalSeconds: 5,
|
||||
});
|
||||
fireEvent.error(currentImg());
|
||||
expect(currentImg().getAttribute('src')).toBe(URL_B);
|
||||
// nur noch ein Bild: kein Wechsel mehr
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(10_000);
|
||||
});
|
||||
expect(currentImg().getAttribute('src')).toBe(URL_B);
|
||||
|
||||
fireEvent.error(currentImg());
|
||||
expect(screen.queryByTestId('picture-frame-image')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('pictureFrame.unavailable')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Test 9: Klick auf das Bild oeffnet die Grossansicht (role=dialog, grosses Bild, Unterschrift); Escape schliesst, Fokus zurueck am Bild-Knopf', () => {
|
||||
renderWidget({ images: [{ kind: 'url', url: URL_A, caption: 'Urlaub' }] });
|
||||
const openButton = screen.getByRole('button', { name: 'pictureFrame.open' });
|
||||
fireEvent.click(openButton);
|
||||
|
||||
const dialog = screen.getByRole('dialog');
|
||||
expect(dialog).toBeInTheDocument();
|
||||
expect(document.activeElement).toBe(dialog);
|
||||
expect(screen.getByTestId('picture-frame-lightbox-image').getAttribute('src')).toBe(URL_A);
|
||||
expect(screen.getByTestId('picture-frame-lightbox-image').getAttribute('referrerpolicy')).toBe('no-referrer');
|
||||
expect(screen.getByTestId('picture-frame-lightbox-caption')).toHaveTextContent('Urlaub');
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
expect(document.activeElement).toBe(openButton);
|
||||
});
|
||||
|
||||
it('Test 10: Hintergrund-Knopf und Schliessen-Knopf schliessen die Grossansicht', () => {
|
||||
renderWidget({ images: [{ kind: 'url', url: URL_A }] });
|
||||
const openButton = screen.getByRole('button', { name: 'pictureFrame.open' });
|
||||
|
||||
fireEvent.click(openButton);
|
||||
fireEvent.click(screen.getByTestId('picture-frame-lightbox-backdrop'));
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(openButton);
|
||||
fireEvent.click(screen.getByTestId('picture-frame-lightbox-close'));
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
expect(document.activeElement).toBe(openButton);
|
||||
});
|
||||
|
||||
it('Test 11: bei offener Grossansicht pausiert der Wechsel; nach dem Schliessen laeuft er weiter', () => {
|
||||
renderWidget({
|
||||
images: [
|
||||
{ kind: 'url', url: URL_A },
|
||||
{ kind: 'url', url: URL_B },
|
||||
],
|
||||
intervalSeconds: 5,
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'pictureFrame.open' }));
|
||||
// Kein Timer-Zaehler hier: nach einer Interaktion haelt React selbst
|
||||
// kurz einen Scheduler-Timer (gemessen: 1 nach dem Oeffnen, obwohl das
|
||||
// Intervall geraeumt ist). Belegt wird das Verhalten — vier Intervalle
|
||||
// vergehen, das Bild bleibt.
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(20_000);
|
||||
});
|
||||
expect(screen.getByTestId('picture-frame-lightbox-image').getAttribute('src')).toBe(URL_A);
|
||||
expect(currentImg().getAttribute('src')).toBe(URL_A);
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
});
|
||||
expect(currentImg().getAttribute('src')).toBe(URL_B);
|
||||
});
|
||||
|
||||
it('Test 12: Bearbeitungsmodus — kein Knopf im Widget, Klick auf das Bild oeffnet nichts', () => {
|
||||
renderWidget({ images: [{ kind: 'url', url: URL_A, caption: 'Urlaub' }] }, true);
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
fireEvent.click(currentImg());
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
// Bild und Streifen werden trotzdem gezeigt
|
||||
expect(currentImg().getAttribute('src')).toBe(URL_A);
|
||||
expect(screen.getByTestId('picture-frame-caption')).toHaveTextContent('Urlaub');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user