8b45a281be
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>
288 lines
13 KiB
TypeScript
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('—');
|
|
});
|
|
});
|