Files
tessera-ctl/apps/web/src/components/settings/widget-settings-panel.test.tsx
T
schalli 8b45a281be fix(dashboard): Bilderrahmen - "1 Stunde" statt "60 Minuten", Bildanzahl in der Einstellungs-Kopfzeile
Zwei Kosmetik-Punkte nach dem Browser-Rundgang vom 21.09.2026:

- Das laengste Wechselintervall (3600 s) hiess "60 Minuten", beim XFrame
  heisst dieselbe Stufe "Jede Stunde". Neuer Schluessel
  `pictureFrame.intervalHours` (ICU-Plural, de + en).
- Unter Einstellungen -> Dashboard zeigte "XFrame #1 - Board" seinen Titel,
  "Bilderrahmen #1" nichts. Die Kopfzeile nennt jetzt "- 1 Bild" bzw.
  "- N Bilder" (Schluessel `imageCountOne`/`imageCountMany`, zwei Schluessel
  statt ICU, weil die Panel-Tests eine einfache Uebersetzungs-Attrappe nutzen).

Web-Tests 603 -> 604, type-check und lint unveraendert gruen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 08:21:23 +02:00

288 lines
13 KiB
TypeScript

import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import de from '@/messages/de.json';
import { updateWidgetConfig } from '@/lib/dashboard-api';
import { WidgetSettingsPanel } from './widget-settings-panel';
/**
* widget-settings-panel.test — NEU (quick-260916-bwo). Das Einstellungsfeld
* hatte bisher keine Testdatei. Vier Tests fuer das Zahlenfeld
* „Schriftgroesse der Uhrzeit (Punkt)“: Vorbelegung, Uebernahme bei Blur,
* leer = automatisch (null) bei Enter, Grenzen 8..200 mit Fehlermeldung und
* OHNE Aufruf (T-BWO-01 — die Grenzen leben in clock-font-size.ts und gelten
* fuer Formular und Widget gleichermassen). Texte aus der echten de.json.
*/
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 any)[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;
},
};
});
vi.mock('@/lib/dashboard-api', () => ({
updateWidgetConfig: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('next/link', () => ({
default: ({ href, children }: { href: string; children: React.ReactNode }) => <a href={href}>{children}</a>,
}));
vi.mock('@/components/settings/search-provider-form', () => ({
SearchProviderForm: () => null,
}));
const clockTexts = (de as { widgets: { clock: Record<string, string> } }).widgets.clock;
const cal = (de as { widgets: { calendar: Record<string, string> } }).widgets.calendar;
const WIDGET = { id: 'c1', widgetType: 'clock', config: { timezone: 'Europe/Berlin', timeFontSizePt: 24 } };
function renderExpanded() {
const onWidgetUpdate = vi.fn();
render(<WidgetSettingsPanel widgets={[WIDGET]} onWidgetUpdate={onWidgetUpdate} />);
fireEvent.click(screen.getByRole('button', { name: /Uhr #1/ }));
const input = screen.getByLabelText(clockTexts.fontSizeLabel) as HTMLInputElement;
return { onWidgetUpdate, input };
}
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
describe('WidgetSettingsPanel — Schriftgroesse der Uhrzeit (quick-260916-bwo)', () => {
it('Test 1: das Zahlenfeld ist aus der Konfiguration vorbelegt, mit Grenzen, Platzhalter und Hilfetext', () => {
const { input } = renderExpanded();
expect(input.value).toBe('24');
expect(input.type).toBe('number');
expect(input.min).toBe('8');
expect(input.max).toBe('200');
expect(input.placeholder).toBe(clockTexts.fontSizeAuto);
expect(screen.getByText(clockTexts.fontSizeHint)).toBeInTheDocument();
});
it('Test 2: eine Zahl im Bereich wird beim Verlassen des Felds uebernommen', async () => {
const { input, onWidgetUpdate } = renderExpanded();
fireEvent.change(input, { target: { value: '36' } });
fireEvent.blur(input);
await vi.waitFor(() => expect(onWidgetUpdate).toHaveBeenCalledWith('c1', { timeFontSizePt: 36 }));
expect(updateWidgetConfig).toHaveBeenCalledTimes(1);
expect(updateWidgetConfig).toHaveBeenCalledWith('c1', { timeFontSizePt: 36 });
});
it('Test 3: leeres Feld plus Enter -> automatisch (timeFontSizePt null), keine Fehlermeldung', async () => {
const { input } = renderExpanded();
fireEvent.change(input, { target: { value: '' } });
fireEvent.keyDown(input, { key: 'Enter' });
await vi.waitFor(() => expect(updateWidgetConfig).toHaveBeenCalledWith('c1', { timeFontSizePt: null }));
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
it('Test 4: ausserhalb 8..200 -> Fehlermeldung, aria-invalid, KEIN Aufruf', () => {
const { input } = renderExpanded();
fireEvent.change(input, { target: { value: '300' } });
fireEvent.blur(input);
expect(updateWidgetConfig).not.toHaveBeenCalled();
expect(screen.getByRole('alert')).toHaveTextContent(clockTexts.fontSizeInvalid);
expect(input.getAttribute('aria-invalid')).toBe('true');
fireEvent.change(input, { target: { value: '7' } });
fireEvent.blur(input);
expect(updateWidgetConfig).not.toHaveBeenCalled();
expect(screen.getByRole('alert')).toBeInTheDocument();
});
});
describe('WidgetSettingsPanel — Kalender-Einstellungen (quick-260916-htc)', () => {
function renderCalendarExpanded(config: Record<string, unknown> = {}) {
const onWidgetUpdate = vi.fn();
const widget = { id: 'k1', widgetType: 'calendar', config };
render(<WidgetSettingsPanel widgets={[widget]} onWidgetUpdate={onWidgetUpdate} />);
fireEvent.click(screen.getByRole('button', { name: /Kalender #1/ }));
return { onWidgetUpdate };
}
it('Test 5: Vorbelegung aus der Konfiguration, Optionslisten und uebersetzte Link-Zeile', () => {
renderCalendarExpanded();
const showMonth = screen.getByLabelText(cal.configShowMonth) as HTMLInputElement;
expect(showMonth.checked).toBe(true);
const maxEventsSelect = screen.getByLabelText(cal.configMaxEvents) as HTMLSelectElement;
expect(maxEventsSelect.value).toBe('3');
const maxEventsOptionTexts = Array.from(maxEventsSelect.options).map((o) => o.textContent);
expect(maxEventsOptionTexts).toEqual([
cal.configMaxEventsNone,
cal.configMaxEventsOne,
...Array.from({ length: 9 }, (_, i) => cal.configMaxEventsMany.replace('{count}', String(i + 2))),
]);
const lookaheadSelect = screen.getByLabelText(cal.configLookahead) as HTMLSelectElement;
expect(lookaheadSelect.value).toBe('30');
const lookaheadOptionTexts = Array.from(lookaheadSelect.options).map((o) => o.textContent);
expect(lookaheadOptionTexts).toEqual(
[7, 14, 30, 60, 90].map((days) => cal.configLookaheadOption.replace('{days}', String(days))),
);
expect(screen.getByText(cal.configSourcesHint)).toBeInTheDocument();
const link = screen.getByRole('link', { name: cal.configSourcesLink });
expect(link).toHaveAttribute('href', '/settings/dashboard/calendar');
expect(screen.queryByText(/managed under/)).not.toBeInTheDocument();
});
it('Test 6: jede Aenderung ruft updateWidgetConfig/onWidgetUpdate nur mit dem geaenderten Feld auf', async () => {
const { onWidgetUpdate } = renderCalendarExpanded();
fireEvent.change(screen.getByLabelText(cal.configMaxEvents), { target: { value: '5' } });
await vi.waitFor(() => expect(onWidgetUpdate).toHaveBeenCalledWith('k1', { maxEvents: 5 }));
expect(updateWidgetConfig).toHaveBeenCalledWith('k1', { maxEvents: 5 });
fireEvent.change(screen.getByLabelText(cal.configLookahead), { target: { value: '14' } });
await vi.waitFor(() => expect(onWidgetUpdate).toHaveBeenCalledWith('k1', { lookaheadDays: 14 }));
expect(updateWidgetConfig).toHaveBeenCalledWith('k1', { lookaheadDays: 14 });
fireEvent.click(screen.getByLabelText(cal.configShowMonth));
await vi.waitFor(() => expect(onWidgetUpdate).toHaveBeenCalledWith('k1', { showMonth: false }));
expect(updateWidgetConfig).toHaveBeenCalledWith('k1', { showMonth: false });
expect(updateWidgetConfig).toHaveBeenCalledTimes(3);
});
it('Test 7: bestehende Werte werden angezeigt, ungueltige Werte geklemmt', () => {
renderCalendarExpanded({ showMonth: false, maxEvents: 7, lookaheadDays: 60 });
expect((screen.getByLabelText(cal.configShowMonth) as HTMLInputElement).checked).toBe(false);
expect((screen.getByLabelText(cal.configMaxEvents) as HTMLSelectElement).value).toBe('7');
expect((screen.getByLabelText(cal.configLookahead) as HTMLSelectElement).value).toBe('60');
cleanup();
renderCalendarExpanded({ maxEvents: 42, lookaheadDays: 45 });
expect((screen.getByLabelText(cal.configMaxEvents) as HTMLSelectElement).value).toBe('10');
expect((screen.getByLabelText(cal.configLookahead) as HTMLSelectElement).value).toBe('30');
});
});
const fav = (de as { widgets: { favorites: Record<string, string> } }).widgets.favorites;
const note = (de as { widgets: { note: Record<string, string> } }).widgets.note;
describe('WidgetSettingsPanel — Favoriten-Titel (quick-260916-iex)', () => {
function renderFavoritesExpanded(config: Record<string, unknown> = {}) {
const onWidgetUpdate = vi.fn();
const widget = { id: 'f1', widgetType: 'favorites', config };
render(<WidgetSettingsPanel widgets={[widget]} onWidgetUpdate={onWidgetUpdate} />);
fireEvent.click(screen.getByRole('button', { name: /Favoriten #1/ }));
return { onWidgetUpdate };
}
it('Test B1: Instanz-Kopfzeile zeigt "— Titel", Feld vorbelegt, Aenderung ruft updateWidgetConfig/onWidgetUpdate auf', async () => {
const { onWidgetUpdate } = renderFavoritesExpanded({ title: 'Werkzeuge' });
expect(screen.getByRole('button', { name: /Favoriten #1/ })).toHaveTextContent('— Werkzeuge');
const input = screen.getByLabelText(fav.titleLabel) as HTMLInputElement;
expect(input.value).toBe('Werkzeuge');
fireEvent.change(input, { target: { value: 'Werkzeuge 2' } });
await vi.waitFor(() =>
expect(onWidgetUpdate).toHaveBeenCalledWith('f1', { title: 'Werkzeuge 2' }),
);
expect(updateWidgetConfig).toHaveBeenCalledWith('f1', { title: 'Werkzeuge 2' });
});
it('Test B2: ohne Titel bzw. mit Leerraum-Titel keine "—" in der Kopfzeile, Feld leer', () => {
renderFavoritesExpanded({});
expect(screen.getByRole('button', { name: /Favoriten #1/ })).not.toHaveTextContent('—');
expect((screen.getByLabelText(fav.titleLabel) as HTMLInputElement).value).toBe('');
cleanup();
renderFavoritesExpanded({ title: ' ' });
expect(screen.getByRole('button', { name: /Favoriten #1/ })).not.toHaveTextContent('—');
expect((screen.getByLabelText(fav.titleLabel) as HTMLInputElement).value).toBe('');
});
it('Test B3: Notiz-Instanz zeigt uebersetzte Beschriftung "Titel" statt hart kodiertem "Title"', () => {
const onWidgetUpdate = vi.fn();
const widget = { id: 'n1', widgetType: 'note', config: { title: 'Einkauf' } };
render(<WidgetSettingsPanel widgets={[widget]} onWidgetUpdate={onWidgetUpdate} />);
fireEvent.click(screen.getByRole('button', { name: /Notizen #1/ }));
const input = screen.getByLabelText(note.titleLabel) as HTMLInputElement;
expect(input.value).toBe('Einkauf');
expect(screen.queryByText('Title')).not.toBeInTheDocument();
});
});
const xframe = (de as { widgets: { xframe: Record<string, string> } }).widgets.xframe;
describe('WidgetSettingsPanel — XFrame (quick-260921-qd3)', () => {
it('Test X1: der Zweig rendert das XFrame-Formular, Kopfzeile zeigt "— Titel", Blur der Adresse ruft updateWidgetConfig/onWidgetUpdate', async () => {
const onWidgetUpdate = vi.fn();
const widget = {
id: 'x1',
widgetType: 'xframe',
config: { url: 'https://intern.example/board', title: 'Board', reloadSeconds: 300 },
};
render(<WidgetSettingsPanel widgets={[widget]} onWidgetUpdate={onWidgetUpdate} />);
const header = screen.getByRole('button', { name: /XFrame #1/ });
expect(header).toHaveTextContent('— Board');
fireEvent.click(header);
const url = screen.getByLabelText(xframe.urlLabel) as HTMLInputElement;
expect(url.value).toBe('https://intern.example/board');
expect((screen.getByLabelText(xframe.titleLabel) as HTMLInputElement).value).toBe('Board');
expect((screen.getByLabelText(xframe.reloadLabel) as HTMLSelectElement).value).toBe('300');
expect(screen.getByText(xframe.embedHint)).toBeInTheDocument();
fireEvent.change(url, { target: { value: 'https://b.de/' } });
fireEvent.blur(url);
await vi.waitFor(() => expect(onWidgetUpdate).toHaveBeenCalledWith('x1', { url: 'https://b.de/' }));
expect(updateWidgetConfig).toHaveBeenCalledTimes(1);
expect(updateWidgetConfig).toHaveBeenCalledWith('x1', { url: 'https://b.de/' });
});
});
describe('WidgetSettingsPanel — Bilderrahmen-Kopfzeile (Kosmetik nach quick-260921-pi9)', () => {
it('Test P1: Kopfzeile zeigt "— 1 Bild" bzw. "— 3 Bilder", ohne Bilder keinen Zusatz', () => {
const one = { id: 'p1', widgetType: 'picture-frame', config: { images: [{ kind: 'url', url: 'https://a.de/x.png' }] } };
const three = {
id: 'p2',
widgetType: 'picture-frame',
config: {
images: [
{ kind: 'url', url: 'https://a.de/1.png' },
{ kind: 'upload', imageId: 'b' },
{ kind: 'url', url: 'https://a.de/3.png' },
],
},
};
const none = { id: 'p3', widgetType: 'picture-frame', config: {} };
render(<WidgetSettingsPanel widgets={[one, three, none]} onWidgetUpdate={vi.fn()} />);
expect(screen.getByRole('button', { name: /Bilderrahmen #1/ })).toHaveTextContent('— 1 Bild');
expect(screen.getByRole('button', { name: /Bilderrahmen #2/ })).toHaveTextContent('— 3 Bilder');
expect(screen.getByRole('button', { name: /Bilderrahmen #3/ })).not.toHaveTextContent('—');
});
});