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; const lookup = (path: string): string | undefined => path.split('.').reduce((o, k) => (o && typeof o === 'object' ? (o as any)[k] : undefined), messages) as | string | undefined; return { useTranslations: (ns?: string) => (key: string, values?: Record) => { 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 }) => {children}, })); vi.mock('@/components/settings/search-provider-form', () => ({ SearchProviderForm: () => null, })); const clockTexts = (de as { widgets: { clock: Record } }).widgets.clock; const cal = (de as { widgets: { calendar: Record } }).widgets.calendar; const WIDGET = { id: 'c1', widgetType: 'clock', config: { timezone: 'Europe/Berlin', timeFontSizePt: 24 } }; function renderExpanded() { const onWidgetUpdate = vi.fn(); render(); 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 = {}) { const onWidgetUpdate = vi.fn(); const widget = { id: 'k1', widgetType: 'calendar', config }; render(); 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 } }).widgets.favorites; const note = (de as { widgets: { note: Record } }).widgets.note; describe('WidgetSettingsPanel — Favoriten-Titel (quick-260916-iex)', () => { function renderFavoritesExpanded(config: Record = {}) { const onWidgetUpdate = vi.fn(); const widget = { id: 'f1', widgetType: 'favorites', config }; render(); 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(); 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 } }).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(); 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(); 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('—'); }); });