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,161 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
PICTURE_FRAME_CAPTION_MAX,
|
||||
PICTURE_FRAME_INTERVAL_DEFAULT,
|
||||
PICTURE_FRAME_INTERVAL_MAX,
|
||||
PICTURE_FRAME_INTERVAL_MIN,
|
||||
PICTURE_FRAME_INTERVAL_OPTIONS,
|
||||
PICTURE_FRAME_MAX_IMAGES,
|
||||
entryKey,
|
||||
isHttpsUrl,
|
||||
pickNextIndex,
|
||||
resolvePictureFrameConfig,
|
||||
} from './picture-frame-config';
|
||||
|
||||
/**
|
||||
* picture-frame-config.test — NEU (quick-260921-pi9, Bilderrahmen).
|
||||
*
|
||||
* Reine Helfer ohne React: die Aufloesung der Widget-Konfiguration (die API
|
||||
* prueft Config-Felder nicht — allein diese Funktion entscheidet, was zum
|
||||
* `<img src>` wird, T-PI9-07), die https-Pruefung, die Wahl des naechsten
|
||||
* Bildes (Zufall nie dasselbe zweimal) und die React-Schluessel.
|
||||
*/
|
||||
describe('resolvePictureFrameConfig (quick-260921-pi9)', () => {
|
||||
it('Test 1: leere Konfiguration -> Vorgaben (keine Bilder, contain, 30 s, Reihenfolge)', () => {
|
||||
expect(resolvePictureFrameConfig({})).toEqual({
|
||||
images: [],
|
||||
fit: 'contain',
|
||||
intervalSeconds: PICTURE_FRAME_INTERVAL_DEFAULT,
|
||||
order: 'sequence',
|
||||
});
|
||||
expect(PICTURE_FRAME_INTERVAL_DEFAULT).toBe(30);
|
||||
});
|
||||
|
||||
it('Test 2: Intervall wird geklemmt — 3 -> 5, 9999 -> 3600, 0 bleibt 0, "abc" -> 30', () => {
|
||||
expect(resolvePictureFrameConfig({ intervalSeconds: 3 }).intervalSeconds).toBe(PICTURE_FRAME_INTERVAL_MIN);
|
||||
expect(resolvePictureFrameConfig({ intervalSeconds: 9999 }).intervalSeconds).toBe(PICTURE_FRAME_INTERVAL_MAX);
|
||||
expect(resolvePictureFrameConfig({ intervalSeconds: 0 }).intervalSeconds).toBe(0);
|
||||
expect(resolvePictureFrameConfig({ intervalSeconds: 'abc' }).intervalSeconds).toBe(30);
|
||||
expect(resolvePictureFrameConfig({ intervalSeconds: Number.NaN }).intervalSeconds).toBe(30);
|
||||
expect(resolvePictureFrameConfig({ intervalSeconds: 7.6 }).intervalSeconds).toBe(8);
|
||||
expect(PICTURE_FRAME_INTERVAL_MIN).toBe(5);
|
||||
expect(PICTURE_FRAME_INTERVAL_MAX).toBe(3600);
|
||||
expect(PICTURE_FRAME_INTERVAL_OPTIONS).toEqual([0, 5, 10, 15, 30, 60, 120, 300, 600, 1800, 3600]);
|
||||
});
|
||||
|
||||
it('Test 3: unbekannte fit/order-Werte fallen auf die Vorgabe zurueck, gueltige bleiben', () => {
|
||||
expect(resolvePictureFrameConfig({ fit: 'x' }).fit).toBe('contain');
|
||||
expect(resolvePictureFrameConfig({ fit: 'cover' }).fit).toBe('cover');
|
||||
expect(resolvePictureFrameConfig({ order: 'x' }).order).toBe('sequence');
|
||||
expect(resolvePictureFrameConfig({ order: 'random' }).order).toBe('random');
|
||||
});
|
||||
|
||||
it('Test 4: Eintraege ohne gueltiges kind, Upload ohne imageId-String und Nicht-Objekte werden weggelassen', () => {
|
||||
const { images } = resolvePictureFrameConfig({
|
||||
images: [
|
||||
{ kind: 'video', url: 'https://a.de/x.mp4' },
|
||||
{ kind: 'upload' },
|
||||
{ kind: 'upload', imageId: 42 },
|
||||
'text',
|
||||
null,
|
||||
{ kind: 'upload', imageId: 'ok-1' },
|
||||
],
|
||||
});
|
||||
expect(images).toEqual([{ kind: 'upload', imageId: 'ok-1' }]);
|
||||
expect(resolvePictureFrameConfig({ images: 'nicht-array' }).images).toEqual([]);
|
||||
});
|
||||
|
||||
it('Test 5: URL-Eintraege mit http://, javascript:, data: oder ohne Parser-Erfolg werden weggelassen (T-PI9-07)', () => {
|
||||
const { images } = resolvePictureFrameConfig({
|
||||
images: [
|
||||
{ kind: 'url', url: 'http://a.de/b.jpg' },
|
||||
{ kind: 'url', url: 'javascript:alert(1)' },
|
||||
{ kind: 'url', url: 'data:image/png;base64,AAAA' },
|
||||
{ kind: 'url', url: 'nicht-url' },
|
||||
{ kind: 'url', url: 'file:///etc/passwd' },
|
||||
{ kind: 'url', url: 42 },
|
||||
{ kind: 'url', url: 'https://a.de/b.jpg' },
|
||||
],
|
||||
});
|
||||
expect(images).toEqual([{ kind: 'url', url: 'https://a.de/b.jpg' }]);
|
||||
});
|
||||
|
||||
it('Test 6: beide Varianten in EINER Liste behalten ihre Reihenfolge (Invariante der Vereinigung)', () => {
|
||||
const { images } = resolvePictureFrameConfig({
|
||||
images: [
|
||||
{ kind: 'url', url: 'https://a.de/1.jpg' },
|
||||
{ kind: 'upload', imageId: 'u-2' },
|
||||
{ kind: 'url', url: 'https://a.de/3.jpg' },
|
||||
],
|
||||
});
|
||||
expect(images.map((e) => (e.kind === 'url' ? e.url : e.imageId))).toEqual([
|
||||
'https://a.de/1.jpg',
|
||||
'u-2',
|
||||
'https://a.de/3.jpg',
|
||||
]);
|
||||
});
|
||||
|
||||
it('Test 7: caption nur als String, auf 200 Zeichen gekuerzt; leere Unterschrift wird weggelassen', () => {
|
||||
const { images } = resolvePictureFrameConfig({
|
||||
images: [
|
||||
{ kind: 'upload', imageId: 'a', caption: 'x'.repeat(300) },
|
||||
{ kind: 'upload', imageId: 'b', caption: 12 },
|
||||
{ kind: 'upload', imageId: 'c', caption: '' },
|
||||
{ kind: 'url', url: 'https://a.de/1.jpg', caption: 'Urlaub' },
|
||||
],
|
||||
});
|
||||
expect(images[0]).toEqual({ kind: 'upload', imageId: 'a', caption: 'x'.repeat(PICTURE_FRAME_CAPTION_MAX) });
|
||||
expect(images[1]).toEqual({ kind: 'upload', imageId: 'b' });
|
||||
expect(images[2]).toEqual({ kind: 'upload', imageId: 'c' });
|
||||
expect(images[3]).toEqual({ kind: 'url', url: 'https://a.de/1.jpg', caption: 'Urlaub' });
|
||||
expect(PICTURE_FRAME_CAPTION_MAX).toBe(200);
|
||||
expect(PICTURE_FRAME_MAX_IMAGES).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isHttpsUrl (quick-260921-pi9)', () => {
|
||||
it('Test 8: https ja (auch grossgeschrieben), http/ftp/javascript/kein-URL nein', () => {
|
||||
expect(isHttpsUrl('https://a.de/b.jpg')).toBe(true);
|
||||
expect(isHttpsUrl('HTTPS://a.de/b.jpg')).toBe(true);
|
||||
expect(isHttpsUrl('http://a.de/b.jpg')).toBe(false);
|
||||
expect(isHttpsUrl('ftp://a.de/b.jpg')).toBe(false);
|
||||
expect(isHttpsUrl('javascript:alert(1)')).toBe(false);
|
||||
expect(isHttpsUrl('nicht-url')).toBe(false);
|
||||
expect(isHttpsUrl('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickNextIndex (quick-260921-pi9)', () => {
|
||||
it('Test 9: count <= 1 -> 0; sequence -> (current + 1) % count', () => {
|
||||
expect(pickNextIndex(0, 0, 'sequence', Math.random)).toBe(0);
|
||||
expect(pickNextIndex(5, 1, 'random', Math.random)).toBe(0);
|
||||
expect(pickNextIndex(0, 3, 'sequence', Math.random)).toBe(1);
|
||||
expect(pickNextIndex(2, 3, 'sequence', Math.random)).toBe(0);
|
||||
});
|
||||
|
||||
it('Test 10: random liefert bei count = 3 ueber 200 Ziehungen nie den aktuellen Index, auch mit gestelltem Zufall', () => {
|
||||
let seed = 1;
|
||||
const random = () => {
|
||||
seed = (seed * 16807) % 2147483647;
|
||||
return (seed - 1) / 2147483646;
|
||||
};
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const current = i % 3;
|
||||
const next = pickNextIndex(current, 3, 'random', random);
|
||||
expect(next).not.toBe(current);
|
||||
expect(next).toBeGreaterThanOrEqual(0);
|
||||
expect(next).toBeLessThan(3);
|
||||
}
|
||||
// Randfaelle des Zufallsgenerators: 0 und knapp unter 1
|
||||
expect(pickNextIndex(0, 3, 'random', () => 0)).not.toBe(0);
|
||||
expect(pickNextIndex(2, 3, 'random', () => 0.999999)).not.toBe(2);
|
||||
expect(pickNextIndex(1, 2, 'random', () => 0.5)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('entryKey (quick-260921-pi9)', () => {
|
||||
it('Test 11: stabiler Schluessel je Variante', () => {
|
||||
expect(entryKey({ kind: 'upload', imageId: 'u-1' }, 3)).toBe('upload:u-1');
|
||||
expect(entryKey({ kind: 'url', url: 'https://a.de/1.jpg' }, 3)).toBe('url:https://a.de/1.jpg:3');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* picture-frame-config — Typen, Grenzen und reine Helfer des Bilderrahmen-
|
||||
* Widgets (quick-260921-pi9). Ohne React-Import, damit der Test schlank
|
||||
* bleibt (Muster clock-font-size.ts, calendar-month.ts).
|
||||
*
|
||||
* Die Bildquelle ist EINE geordnete Liste `images` mit zwei Varianten
|
||||
* (`kind: 'upload'` mit Kennung eines hochgeladenen Bildes, `kind: 'url'`
|
||||
* mit https-Webadresse) — keine parallelen Listen, damit Reihenfolge,
|
||||
* Unterschrift und Wechsel fuer beide dieselbe Logik durchlaufen.
|
||||
*
|
||||
* T-PI9-07: Die API prueft Widget-Konfigurationen nicht inhaltlich
|
||||
* (`@IsObject()`, flache Zusammenfuehrung). Deshalb entscheidet ALLEIN
|
||||
* `resolvePictureFrameConfig`, was zum `<img src>` wird: nur eine Adresse,
|
||||
* die der echte `URL`-Parser als `https:` erkennt. `http:`, `data:`,
|
||||
* `javascript:`, `file:` und Unparsbares fallen still weg — ein von Hand
|
||||
* manipulierter Config-Wert wird auf dem eigenen Dashboard nicht einmal
|
||||
* gerendert. Das Formular prueft mit derselben Funktion, bevor es speichert.
|
||||
*/
|
||||
|
||||
export const PICTURE_FRAME_INTERVAL_MIN = 5;
|
||||
export const PICTURE_FRAME_INTERVAL_MAX = 3600;
|
||||
export const PICTURE_FRAME_INTERVAL_DEFAULT = 30;
|
||||
/** Auswahlwerte des Formulars in Sekunden; 0 = kein Wechsel. */
|
||||
export const PICTURE_FRAME_INTERVAL_OPTIONS = [0, 5, 10, 15, 30, 60, 120, 300, 600, 1800, 3600];
|
||||
/** Gleich der Servergrenze DASHBOARD_IMAGE_MAX_COUNT. */
|
||||
export const PICTURE_FRAME_MAX_IMAGES = 30;
|
||||
export const PICTURE_FRAME_CAPTION_MAX = 200;
|
||||
|
||||
export type PictureFrameEntry =
|
||||
| { kind: 'upload'; imageId: string; caption?: string }
|
||||
| { kind: 'url'; url: string; caption?: string };
|
||||
|
||||
export type PictureFrameFit = 'contain' | 'cover';
|
||||
export type PictureFrameOrder = 'sequence' | 'random';
|
||||
|
||||
export interface PictureFrameConfig {
|
||||
images: PictureFrameEntry[];
|
||||
fit: PictureFrameFit;
|
||||
/** 0 = kein Wechsel, sonst MIN..MAX. */
|
||||
intervalSeconds: number;
|
||||
order: PictureFrameOrder;
|
||||
}
|
||||
|
||||
/** Echter URL-Parser, Protokoll kleingeschrieben verglichen. */
|
||||
export function isHttpsUrl(value: string): boolean {
|
||||
try {
|
||||
return new URL(value).protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCaption(raw: unknown): string | undefined {
|
||||
if (typeof raw !== 'string' || raw === '') return undefined;
|
||||
return raw.slice(0, PICTURE_FRAME_CAPTION_MAX);
|
||||
}
|
||||
|
||||
function resolveEntry(raw: unknown): PictureFrameEntry | null {
|
||||
if (typeof raw !== 'object' || raw === null) return null;
|
||||
const record = raw as Record<string, unknown>;
|
||||
const caption = resolveCaption(record.caption);
|
||||
if (record.kind === 'upload') {
|
||||
if (typeof record.imageId !== 'string' || record.imageId === '') return null;
|
||||
return caption === undefined
|
||||
? { kind: 'upload', imageId: record.imageId }
|
||||
: { kind: 'upload', imageId: record.imageId, caption };
|
||||
}
|
||||
if (record.kind === 'url') {
|
||||
if (typeof record.url !== 'string' || !isHttpsUrl(record.url)) return null;
|
||||
return caption === undefined
|
||||
? { kind: 'url', url: record.url }
|
||||
: { kind: 'url', url: record.url, caption };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveInterval(raw: unknown): number {
|
||||
if (typeof raw !== 'number' || !Number.isFinite(raw)) return PICTURE_FRAME_INTERVAL_DEFAULT;
|
||||
if (raw <= 0) return 0;
|
||||
const rounded = Math.round(raw);
|
||||
return Math.min(PICTURE_FRAME_INTERVAL_MAX, Math.max(PICTURE_FRAME_INTERVAL_MIN, rounded));
|
||||
}
|
||||
|
||||
export function resolvePictureFrameConfig(config: Record<string, unknown>): PictureFrameConfig {
|
||||
const rawImages = Array.isArray(config.images) ? config.images : [];
|
||||
const images: PictureFrameEntry[] = [];
|
||||
for (const raw of rawImages) {
|
||||
const entry = resolveEntry(raw);
|
||||
if (entry) images.push(entry);
|
||||
}
|
||||
return {
|
||||
images,
|
||||
fit: config.fit === 'cover' ? 'cover' : 'contain',
|
||||
intervalSeconds: resolveInterval(config.intervalSeconds),
|
||||
order: config.order === 'random' ? 'random' : 'sequence',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Naechster Index: Reihenfolge zyklisch; Zufall waehlt bei mehr als einem
|
||||
* Bild nie das aktuelle erneut (Ziehung aus count-1 Kandidaten, Verschiebung
|
||||
* ueber das aktuelle hinweg — kein Wiederholen bis es passt).
|
||||
*/
|
||||
export function pickNextIndex(
|
||||
current: number,
|
||||
count: number,
|
||||
order: PictureFrameOrder,
|
||||
random: () => number,
|
||||
): number {
|
||||
if (count <= 1) return 0;
|
||||
if (order === 'sequence') return (current + 1) % count;
|
||||
const draw = Math.min(count - 2, Math.max(0, Math.floor(random() * (count - 1))));
|
||||
return draw >= current ? draw + 1 : draw;
|
||||
}
|
||||
|
||||
/** Stabiler React-Schluessel je Eintrag. */
|
||||
export function entryKey(entry: PictureFrameEntry, index: number): string {
|
||||
return entry.kind === 'upload' ? `upload:${entry.imageId}` : `url:${entry.url}:${index}`;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
interface PictureFrameLightboxProps {
|
||||
src: string;
|
||||
caption?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grossansicht des Bilderrahmen-Widgets (quick-260921-pi9).
|
||||
*
|
||||
* Aufbau wie widget-catalog-modal.tsx: Hintergrund als echte, benannte
|
||||
* Schaltflaeche (schliesst), Dialog `role="dialog" aria-modal` mit
|
||||
* `tabIndex={-1}` und Fokus beim Einhaengen, Escape-Listener mit Aufraeumung,
|
||||
* Schliessen-Knopf oben rechts. Die Fokus-RUECKGABE an den Bild-Knopf macht
|
||||
* das Widget selbst (es kennt den Ausloeser), nicht diese Komponente.
|
||||
*
|
||||
* `referrerPolicy="no-referrer"` auch hier (T-PI9-09): ein Fremdhost erfaehrt
|
||||
* die Tessera-Adresse nicht. Die Unterschrift ist ein React-Textknoten,
|
||||
* kein HTML (T-PI9-06).
|
||||
*/
|
||||
export function PictureFrameLightbox({ src, caption, onClose }: PictureFrameLightboxProps) {
|
||||
const t = useTranslations('widgets');
|
||||
const tCommon = useTranslations('common');
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
dialogRef.current?.focus();
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t('pictureFrame.close')}
|
||||
data-testid="picture-frame-lightbox-backdrop"
|
||||
className="fixed inset-0 bg-black/80"
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={caption ?? t('pictureFrame.open')}
|
||||
className="relative z-50 flex max-h-[95vh] max-w-[95vw] flex-col items-center gap-2 p-4"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
data-testid="picture-frame-lightbox-close"
|
||||
className="absolute -right-1 -top-1 z-10 rounded-full bg-black/60 p-1.5 text-white transition-colors hover:bg-black/80"
|
||||
aria-label={tCommon('close')}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<img
|
||||
src={src}
|
||||
alt={caption ?? ''}
|
||||
referrerPolicy="no-referrer"
|
||||
data-testid="picture-frame-lightbox-image"
|
||||
className="max-h-[85vh] max-w-[90vw] rounded object-contain shadow-2xl"
|
||||
/>
|
||||
{caption && (
|
||||
<p className="max-w-[90vw] truncate text-center text-sm text-white" data-testid="picture-frame-lightbox-caption">
|
||||
{caption}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import type { WidgetProps } from '@/components/dashboard/widget-registry';
|
||||
import { dashboardImageSrc } from '@/lib/dashboard-images-api';
|
||||
import {
|
||||
type PictureFrameEntry,
|
||||
entryKey,
|
||||
pickNextIndex,
|
||||
resolvePictureFrameConfig,
|
||||
} from './picture-frame-config';
|
||||
import { PictureFrameLightbox } from './picture-frame-lightbox';
|
||||
|
||||
/**
|
||||
* PictureFrameWidget — Diashow aus hochgeladenen Bildern und https-Adressen
|
||||
* (quick-260921-pi9).
|
||||
*
|
||||
* Woher die Bytes kommen: hochgeladene Bilder ueber den Next-Rewrite
|
||||
* `/api-proxy/dashboard/images/:id` (Cookie laeuft mit, eigener Origin);
|
||||
* Webadressen laedt AUSSCHLIESSLICH der Browser des Benutzers direkt per
|
||||
* `<img>` — der Server ruft nie eine Adresse ab (T-PI9-05: keine
|
||||
* SSRF-Flaeche, kein Proxy-Endpunkt, der eine URL entgegennimmt; interne
|
||||
* Hosts sieht damit nur, wer sie ohnehin erreicht). `referrerPolicy=
|
||||
* "no-referrer"` an jedem Bild, damit der Fremdhost die Tessera-Adresse
|
||||
* nicht erfaehrt (T-PI9-09).
|
||||
*
|
||||
* Was gerendert wird, entscheidet `resolvePictureFrameConfig` (T-PI9-07):
|
||||
* nur https-Adressen, nur Upload-Eintraege mit Kennung. Ein Bild, das nicht
|
||||
* laedt (`onError`), verlaesst den Umlauf; sind alle kaputt, steht
|
||||
* „Bild nicht verfuegbar“ (Entscheidung 7: leerer Zustand als zentrierter
|
||||
* grauer Text wie die anderen Widgets).
|
||||
*
|
||||
* Wechsel per Timer nur bei > 1 sichtbarem Bild, Intervall > 0 und
|
||||
* geschlossener Grossansicht; Zufall waehlt nie das aktuelle erneut
|
||||
* (`pickNextIndex`); der Timer wird beim Aushaengen und bei jeder Aenderung
|
||||
* der Eingaben geraeumt (Entscheidung 6).
|
||||
*
|
||||
* Klick auf das Bild NUR ausserhalb des Bearbeitungsmodus (Entscheidung 5):
|
||||
* dort liegt Bild + Streifen in einem `<button>` (Grossansicht, Fokus kehrt
|
||||
* nach dem Schliessen zum Knopf zurueck). Im Bearbeitungsmodus ist es ein
|
||||
* `<div>` ohne Handler — die ganze Karte bleibt der Ziehgriff
|
||||
* (widget-wrapper.tsx), ein Knopf wuerde ueber den cancel-Selektor des
|
||||
* Rasters das Ziehen an dieser Stelle verhindern.
|
||||
*/
|
||||
export function PictureFrameWidget({ config, isEditMode }: WidgetProps) {
|
||||
const t = useTranslations('widgets');
|
||||
const resolved = useMemo(() => resolvePictureFrameConfig(config), [config]);
|
||||
const { images, fit, intervalSeconds, order } = resolved;
|
||||
|
||||
const [index, setIndex] = useState(0);
|
||||
const [brokenKeys, setBrokenKeys] = useState<string[]>([]);
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
const openButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const visible = useMemo(
|
||||
() =>
|
||||
images
|
||||
.map((entry, i) => ({ entry, key: entryKey(entry, i) }))
|
||||
.filter(({ key }) => !brokenKeys.includes(key)),
|
||||
[images, brokenKeys],
|
||||
);
|
||||
const count = visible.length;
|
||||
const safeIndex = count === 0 ? 0 : index % count;
|
||||
|
||||
useEffect(() => {
|
||||
if (intervalSeconds <= 0 || count <= 1 || lightboxOpen) return;
|
||||
const timer = setInterval(() => {
|
||||
setIndex((current) => pickNextIndex(current % count, count, order, Math.random));
|
||||
}, intervalSeconds * 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [intervalSeconds, count, order, lightboxOpen]);
|
||||
|
||||
const markBroken = useCallback((key: string) => {
|
||||
setBrokenKeys((prev) => (prev.includes(key) ? prev : [...prev, key]));
|
||||
}, []);
|
||||
|
||||
const closeLightbox = useCallback(() => {
|
||||
setLightboxOpen(false);
|
||||
// Fokus-Rueckgabe an den Ausloeser (Muster: Dialog fokussiert beim
|
||||
// Oeffnen, der Ausloeser bekommt ihn nach dem Schliessen zurueck).
|
||||
openButtonRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
if (images.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center px-2 text-center text-sm text-muted-foreground">
|
||||
{t('pictureFrame.empty')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (count === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center px-2 text-center text-sm text-muted-foreground">
|
||||
{t('pictureFrame.unavailable')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const current = visible[safeIndex];
|
||||
const src = entrySrc(current.entry);
|
||||
const caption = current.entry.caption;
|
||||
const imgClassName = fit === 'cover' ? 'h-full w-full object-cover' : 'h-full w-full object-contain';
|
||||
|
||||
const picture = (
|
||||
<>
|
||||
<img
|
||||
key={current.key}
|
||||
src={src}
|
||||
alt={caption ?? ''}
|
||||
referrerPolicy="no-referrer"
|
||||
loading="lazy"
|
||||
data-testid="picture-frame-image"
|
||||
className={imgClassName}
|
||||
onError={() => markBroken(current.key)}
|
||||
/>
|
||||
{caption && (
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0 truncate bg-black/50 px-2 py-1 text-left text-xs text-white"
|
||||
data-testid="picture-frame-caption"
|
||||
>
|
||||
{caption}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full overflow-hidden">
|
||||
{isEditMode ? (
|
||||
<div className="relative h-full w-full">{picture}</div>
|
||||
) : (
|
||||
<button
|
||||
ref={openButtonRef}
|
||||
type="button"
|
||||
aria-label={t('pictureFrame.open')}
|
||||
className="relative block h-full w-full cursor-zoom-in"
|
||||
onClick={() => setLightboxOpen(true)}
|
||||
>
|
||||
{picture}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{lightboxOpen && !isEditMode && (
|
||||
<PictureFrameLightbox src={src} caption={caption} onClose={closeLightbox} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function entrySrc(entry: PictureFrameEntry): string {
|
||||
return entry.kind === 'upload' ? dashboardImageSrc(entry.imageId) : entry.url;
|
||||
}
|
||||
Reference in New Issue
Block a user