import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import de from '@/messages/de.json'; /** * picture-frame-config-form.test — NEU (quick-260921-pi9). Texte aus der * echten de.json (Muster widget-settings-panel.test.tsx): Auswahlfelder, * Eintragsliste mit Vorschau/Unterschrift/Pfeilen/Entfernen, Upload-Kette, * https-Pruefung, Grenze 30. */ vi.mock('next-intl', async (importOriginal) => { // Echter ICU-Uebersetzer von next-intl auf der echten de.json: die // Intervall-Beschriftung nutzt `{n, plural, one {…} other {…}}`, ein // handgeschriebener `{n}`-Ersatz wuerde das Muster nicht verstehen. const actual = await importOriginal(); const messages = (await import('@/messages/de.json')).default; return { useTranslations: (ns?: string) => actual.createTranslator({ locale: 'de', messages, namespace: ns }), }; }); const { uploadMock, deleteMock } = vi.hoisted(() => ({ uploadMock: vi.fn(), deleteMock: vi.fn(), })); vi.mock('@/lib/dashboard-images-api', () => ({ uploadDashboardImage: uploadMock, deleteDashboardImage: deleteMock, dashboardImageSrc: (id: string) => `/api-proxy/dashboard/images/${encodeURIComponent(id)}`, })); import { PictureFrameConfigForm } from './picture-frame-config-form'; const texts = (de as { widgets: { pictureFrame: Record } }).widgets.pictureFrame; const URL_A = 'https://bilder.example/a.jpg'; function renderForm(config: Record) { const onChange = vi.fn(); render(); return { onChange }; } beforeEach(() => { uploadMock.mockReset(); deleteMock.mockReset(); deleteMock.mockResolvedValue(undefined); }); afterEach(() => { cleanup(); }); describe('PictureFrameConfigForm (quick-260921-pi9)', () => { it('Test 1: drei Auswahlfelder mit Vorgaben; jede Aenderung ruft onChange mit genau dem einen Feld', () => { const { onChange } = renderForm({}); const fit = screen.getByLabelText(texts.fitLabel) as HTMLSelectElement; const interval = screen.getByLabelText(texts.intervalLabel) as HTMLSelectElement; const order = screen.getByLabelText(texts.orderLabel) as HTMLSelectElement; expect(fit.value).toBe('contain'); expect(interval.value).toBe('30'); expect(order.value).toBe('sequence'); expect(Array.from(interval.options).map((o) => o.value)).toEqual([ '0', '5', '10', '15', '30', '60', '120', '300', '600', '1800', '3600', ]); expect(Array.from(interval.options).map((o) => o.textContent)).toContain('Kein Wechsel'); expect(Array.from(interval.options).map((o) => o.textContent)).toContain('5 Sekunden'); expect(Array.from(interval.options).map((o) => o.textContent)).toContain('30 Minuten'); expect(Array.from(interval.options).map((o) => o.textContent)).toContain('1 Minute'); fireEvent.change(fit, { target: { value: 'cover' } }); expect(onChange).toHaveBeenLastCalledWith({ fit: 'cover' }); fireEvent.change(interval, { target: { value: '120' } }); expect(onChange).toHaveBeenLastCalledWith({ intervalSeconds: 120 }); fireEvent.change(order, { target: { value: 'random' } }); expect(onChange).toHaveBeenLastCalledWith({ order: 'random' }); expect(onChange).toHaveBeenCalledTimes(3); }); it('Test 2: Liste zeigt je Eintrag Vorschau (no-referrer, Upload ueber Proxy-Pfad); kaputte Vorschau wird zu "Bild nicht verfügbar"', () => { renderForm({ images: [ { kind: 'url', url: URL_A }, { kind: 'upload', imageId: 'img-1' }, ], }); const previews = screen.getAllByTestId('picture-frame-preview') as HTMLImageElement[]; expect(previews).toHaveLength(2); expect(previews[0].getAttribute('src')).toBe(URL_A); expect(previews[0].getAttribute('referrerpolicy')).toBe('no-referrer'); expect(previews[1].getAttribute('src')).toBe('/api-proxy/dashboard/images/img-1'); fireEvent.error(previews[0]); expect(screen.getAllByTestId('picture-frame-preview')).toHaveLength(1); expect(screen.getByText(texts.unavailable)).toBeInTheDocument(); }); it('Test 3: Unterschrift — Entwurf im Feld, Uebernahme bei Blur/Enter mit dem GANZEN images-Array; unveraendert -> kein Aufruf', () => { const { onChange } = renderForm({ images: [ { kind: 'url', url: URL_A, caption: 'Alt' }, { kind: 'upload', imageId: 'img-1' }, ], }); const inputs = screen.getAllByLabelText(texts.captionPlaceholder) as HTMLInputElement[]; expect(inputs[0].value).toBe('Alt'); expect(inputs[1].value).toBe(''); fireEvent.change(inputs[1], { target: { value: 'Neu' } }); expect(onChange).not.toHaveBeenCalled(); fireEvent.keyDown(inputs[1], { key: 'Enter' }); expect(onChange).toHaveBeenCalledWith({ images: [ { kind: 'url', url: URL_A, caption: 'Alt' }, { kind: 'upload', imageId: 'img-1', caption: 'Neu' }, ], }); // Leer machen entfernt die Unterschrift, Blur uebernimmt fireEvent.change(inputs[0], { target: { value: ' ' } }); fireEvent.blur(inputs[0]); expect(onChange).toHaveBeenLastCalledWith({ images: [ { kind: 'url', url: URL_A }, { kind: 'upload', imageId: 'img-1' }, ], }); // Unveraendert: kein weiterer Aufruf const calls = onChange.mock.calls.length; fireEvent.blur(inputs[1]); expect(onChange).toHaveBeenCalledTimes(calls); }); it('Test 4: Pfeile — oberster ohne "hoch", unterster ohne "runter"; Bewegen sendet das ganze Array in neuer Reihenfolge', () => { const { onChange } = renderForm({ images: [ { kind: 'url', url: URL_A }, { kind: 'upload', imageId: 'img-1' }, { kind: 'upload', imageId: 'img-2' }, ], }); const ups = screen.getAllByRole('button', { name: texts.moveUpButton }) as HTMLButtonElement[]; const downs = screen.getAllByRole('button', { name: texts.moveDownButton }) as HTMLButtonElement[]; expect(ups[0].disabled).toBe(true); expect(ups[1].disabled).toBe(false); expect(downs[2].disabled).toBe(true); expect(downs[1].disabled).toBe(false); fireEvent.click(downs[0]); expect(onChange).toHaveBeenLastCalledWith({ images: [ { kind: 'upload', imageId: 'img-1' }, { kind: 'url', url: URL_A }, { kind: 'upload', imageId: 'img-2' }, ], }); fireEvent.click(ups[2]); expect(onChange).toHaveBeenLastCalledWith({ images: [ { kind: 'url', url: URL_A }, { kind: 'upload', imageId: 'img-2' }, { kind: 'upload', imageId: 'img-1' }, ], }); }); it('Test 5: Entfernen eines Upload-Eintrags ruft deleteDashboardImage (Fehler verschluckt) UND onChange mit dem verkuerzten Array; URL-Eintrag ohne Serveraufruf', async () => { deleteMock.mockRejectedValue(new Error('weg')); const { onChange } = renderForm({ images: [ { kind: 'upload', imageId: 'img-1' }, { kind: 'url', url: URL_A }, ], }); const removes = screen.getAllByRole('button', { name: texts.removeButton }); fireEvent.click(removes[0]); expect(deleteMock).toHaveBeenCalledWith('img-1'); expect(onChange).toHaveBeenLastCalledWith({ images: [{ kind: 'url', url: URL_A }] }); await act(async () => { await Promise.resolve(); }); fireEvent.click(removes[1]); expect(deleteMock).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenLastCalledWith({ images: [{ kind: 'upload', imageId: 'img-1' }] }); }); it('Test 6: Webadresse — http:// zeigt role=alert und ruft onChange nicht; https ruft onChange mit angehaengtem URL-Eintrag', () => { const { onChange } = renderForm({ images: [{ kind: 'upload', imageId: 'img-1' }] }); const input = screen.getByLabelText(texts.urlAddButton) as HTMLInputElement; const button = screen.getByRole('button', { name: texts.urlAddButton }); fireEvent.change(input, { target: { value: 'http://bilder.example/a.jpg' } }); fireEvent.click(button); expect(screen.getByRole('alert')).toHaveTextContent(texts.urlInvalid); expect(onChange).not.toHaveBeenCalled(); fireEvent.change(input, { target: { value: URL_A } }); expect(screen.queryByRole('alert')).not.toBeInTheDocument(); fireEvent.keyDown(input, { key: 'Enter' }); expect(onChange).toHaveBeenCalledWith({ images: [ { kind: 'upload', imageId: 'img-1' }, { kind: 'url', url: URL_A }, ], }); expect(input.value).toBe(''); }); it('Test 7: Datei waehlen ruft uploadDashboardImage(file) und danach onChange mit neuem Upload-Eintrag (Kennung aus der Antwort)', async () => { uploadMock.mockResolvedValue({ id: 'neu-42', originalName: 'a.png', mimeType: 'image/png', size: 3 }); const { onChange } = renderForm({ images: [{ kind: 'url', url: URL_A }] }); const file = new File([new Uint8Array([1, 2, 3])], 'a.png', { type: 'image/png' }); const input = screen.getByTestId('picture-frame-file-input') as HTMLInputElement; await act(async () => { fireEvent.change(input, { target: { files: [file] } }); }); expect(uploadMock).toHaveBeenCalledWith(file); await waitFor(() => expect(onChange).toHaveBeenCalledWith({ images: [ { kind: 'url', url: URL_A }, { kind: 'upload', imageId: 'neu-42' }, ], }), ); expect(screen.queryByRole('alert')).not.toBeInTheDocument(); }); it('Test 8: wirft der Upload, erscheint seine Meldung als role=alert und onChange bleibt aus', async () => { uploadMock.mockRejectedValue(new Error('Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.')); const { onChange } = renderForm({}); const input = screen.getByTestId('picture-frame-file-input') as HTMLInputElement; await act(async () => { fireEvent.change(input, { target: { files: [new File([1], 'x.png')] } }); }); await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent( 'Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.', ), ); expect(onChange).not.toHaveBeenCalled(); }); it('Test 9: bei 30 Eintraegen ist "Bild hochladen" deaktiviert, der Hinweis limitReached steht, Webadresse ebenfalls gesperrt', () => { const images = Array.from({ length: 30 }, (_, i) => ({ kind: 'upload', imageId: `img-${i}` })); renderForm({ images }); const upload = screen.getByRole('button', { name: texts.uploadButton }) as HTMLButtonElement; expect(upload.disabled).toBe(true); expect(screen.getByTestId('picture-frame-limit')).toHaveTextContent(texts.limitReached); expect((screen.getByRole('button', { name: texts.urlAddButton }) as HTMLButtonElement).disabled).toBe(true); expect(screen.getAllByTestId('picture-frame-entry')).toHaveLength(30); }); });