From 377b6e37b247609fbb62ea90ba555e6574984e3b Mon Sep 17 00:00:00 2001 From: Schalli Date: Thu, 24 Sep 2026 13:30:22 +0200 Subject: [PATCH] test(260924-i8v): RED fuer Titel und Serverauswahl der Proxmox-Kachel - Auswahl-Bauteil: Reihenfolge nach position, Produktwort, Aufraeumen geloeschter Kennungen - Einstellungsformular: Laden, Fehler, Leer, onChange fuer Titel und Auswahl - Einstellungsbereich: Zweig proxmox mit Titel-Zusatz in der Kopfzeile - Kachel im Bearbeitungsmodus: entprellter Titel, Auswahl statt Liste, schliesst beim Verlassen Co-Authored-By: Claude Opus 5.5 (1M context) --- .../dashboard/widgets/proxmox-widget.test.tsx | 121 +++++++++++++++++- .../proxmox/proxmox-server-picker.test.tsx | 118 +++++++++++++++++ .../proxmox-widget-config-form.test.tsx | 118 +++++++++++++++++ .../settings/widget-settings-panel.test.tsx | 48 +++++++ 4 files changed, 404 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/proxmox/proxmox-server-picker.test.tsx create mode 100644 apps/web/src/components/settings/proxmox-widget-config-form.test.tsx diff --git a/apps/web/src/components/dashboard/widgets/proxmox-widget.test.tsx b/apps/web/src/components/dashboard/widgets/proxmox-widget.test.tsx index fc724f8..9afbfb3 100644 --- a/apps/web/src/components/dashboard/widgets/proxmox-widget.test.tsx +++ b/apps/web/src/components/dashboard/widgets/proxmox-widget.test.tsx @@ -1,4 +1,12 @@ -import { act, cleanup, render as rtlRender, screen, waitFor, within } from '@testing-library/react'; +import { + act, + cleanup, + fireEvent, + render as rtlRender, + screen, + waitFor, + within, +} from '@testing-library/react'; import { NextIntlClientProvider } from 'next-intl'; import type { ReactElement } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -37,6 +45,11 @@ vi.mock('next/link', () => ({ ), })); +const mockUpdateWidgetConfig = vi.fn(); +vi.mock('@/lib/dashboard-api', () => ({ + updateWidgetConfig: (...args: unknown[]) => mockUpdateWidgetConfig(...args), +})); + function mockUser(user: { role: 'SUPER_ADMIN' | 'ADMIN' | 'USER' } | null) { mockAuthStore.mockImplementation((selector: (state: { user: typeof user }) => unknown) => selector({ user }), @@ -521,3 +534,109 @@ describe('ProxmoxWidget: Minutentakt (quick-260924-i8v, Aufgabe 2)', () => { expect(screen.queryByText('Die Serverliste konnte nicht geladen werden.')).toBeNull(); }); }); + +describe('ProxmoxWidget: Bearbeitungsmodus mit Titel und Serverauswahl (quick-260924-i8v, Aufgabe 3)', () => { + beforeEach(() => { + mockUpdateWidgetConfig.mockResolvedValue(undefined); + }); + + it('Titelfeld speichert entprellt nach 1500 ms per updateWidgetConfig({ title })', async () => { + mockListServers.mockResolvedValue([makeServer('ok')]); + await renderWidget({ isEditMode: true, config: { title: 'Alt' } }); + await screen.findByTestId('proxmox-summary'); + + const input = screen.getByLabelText('Titel') as HTMLInputElement; + expect(input.value).toBe('Alt'); + expect(input).toHaveAttribute('placeholder', 'Titel (optional)'); + expect(input).toHaveClass('widgetNoDrag'); + + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + try { + fireEvent.change(input, { target: { value: 'N' } }); + fireEvent.change(input, { target: { value: 'Neu' } }); + vi.advanceTimersByTime(1499); + expect(mockUpdateWidgetConfig).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(mockUpdateWidgetConfig).toHaveBeenCalledTimes(1); + expect(mockUpdateWidgetConfig).toHaveBeenCalledWith('w-1', { title: 'Neu' }); + } finally { + vi.useRealTimers(); + } + }); + + it('im Bearbeitungsmodus steht die Kopfzeile auch ohne Titel, mit Abstand zur Griffleiste', async () => { + mockListServers.mockResolvedValue([makeServer('ok')]); + const { container } = await renderWidget({ isEditMode: true }); + await screen.findByTestId('proxmox-summary'); + + expect(screen.getByLabelText('Titel')).toBeInTheDocument(); + expect(screen.queryByRole('heading')).toBeNull(); + expect(container.querySelector('[data-testid="proxmox-widget"]')).toHaveClass('pt-5'); + }); + + it('„Server auswählen“ oeffnet die Auswahl an Stelle der Liste; eine Aenderung speichert sofort und filtert', async () => { + mockListServers.mockResolvedValue([ + makeServer('ok', { id: 'a', name: 'pve-a', position: 0 }), + makeServer('down', { id: 'b', name: 'pve-b', position: 1 }), + ]); + await renderWidget({ isEditMode: true }); + expect(await screen.findByTestId('proxmox-summary')).toHaveTextContent('1 nicht erreichbar'); + + const toggle = screen.getByRole('button', { name: 'Server auswählen' }); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + expect(toggle).toHaveAttribute('data-no-drag'); + fireEvent.click(toggle); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + + expect(screen.queryByTestId('proxmox-list')).toBeNull(); + const group = screen.getByRole('group', { name: 'Angezeigte Server' }); + expect(group.closest('.widgetNoDrag')).not.toBeNull(); + + fireEvent.click(screen.getByRole('checkbox', { name: /pve-a/ })); + + expect(mockUpdateWidgetConfig).toHaveBeenCalledWith('w-1', { serverIds: ['a'] }); + expect(screen.getByTestId('proxmox-summary')).toHaveTextContent('Alles in Ordnung'); + // Kein zusaetzlicher Abruf fuer die Auswahl + expect(mockListServers).toHaveBeenCalledTimes(1); + expect(mockPollServer).not.toHaveBeenCalled(); + }); + + it('die Auswahl bekommt die ungefilterte Liste', async () => { + mockListServers.mockResolvedValue([ + makeServer('ok', { id: 'a', name: 'pve-a' }), + makeServer('ok', { id: 'b', name: 'pve-b' }), + ]); + await renderWidget({ isEditMode: true, config: { serverIds: ['a'] } }); + await screen.findByTestId('proxmox-summary'); + + fireEvent.click(screen.getByRole('button', { name: 'Server auswählen' })); + + expect(screen.getByRole('checkbox', { name: /pve-a/ })).toBeChecked(); + expect(screen.getByRole('checkbox', { name: /pve-b/ })).not.toBeChecked(); + }); + + it('Verlassen des Bearbeitungsmodus schliesst die Auswahl', async () => { + mockListServers.mockResolvedValue([makeServer('ok')]); + const { ProxmoxWidget } = await import('./proxmox-widget'); + const ui = (isEditMode: boolean) => ( + + + + ); + const { rerender } = rtlRender(ui(true)); + await screen.findByTestId('proxmox-summary'); + + fireEvent.click(screen.getByRole('button', { name: 'Server auswählen' })); + expect(screen.getByRole('group', { name: 'Angezeigte Server' })).toBeInTheDocument(); + + rerender(ui(false)); + expect(screen.queryByRole('group', { name: 'Angezeigte Server' })).toBeNull(); + expect(screen.getByTestId('proxmox-list')).toBeInTheDocument(); + + rerender(ui(true)); + expect(screen.getByRole('button', { name: 'Server auswählen' })).toHaveAttribute( + 'aria-expanded', + 'false', + ); + }); +}); diff --git a/apps/web/src/components/proxmox/proxmox-server-picker.test.tsx b/apps/web/src/components/proxmox/proxmox-server-picker.test.tsx new file mode 100644 index 0000000..c865864 --- /dev/null +++ b/apps/web/src/components/proxmox/proxmox-server-picker.test.tsx @@ -0,0 +1,118 @@ +import { cleanup, fireEvent, render as rtlRender, screen } from '@testing-library/react'; +import { NextIntlClientProvider } from 'next-intl'; +import type { ReactElement } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { ProxmoxProductType, ProxmoxServer } from '@/lib/proxmox-api'; +import de from '@/messages/de.json'; +import { ProxmoxServerPicker } from './proxmox-server-picker'; + +/** + * Auswahl-Bauteil der Proxmox-Kachel (quick-260924-i8v) — gemeinsam fuer die + * Kachel im Bearbeitungsmodus und Einstellungen > Dashboard. + */ +function render(ui: ReactElement) { + return rtlRender( + + {ui} + , + ); +} + +function server(id: string, productType: ProxmoxProductType, position: number): ProxmoxServer { + return { + id, + tenantId: 't1', + name: id, + productType, + baseUrl: 'https://x.intern', + authMethod: 'token', + tokenId: 'a@pam!b', + username: null, + tlsRejectUnauthorized: true, + isActive: true, + pollIntervalMin: 5, + position, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + status: null, + }; +} + +// Absichtlich NICHT nach position geliefert. +const SERVERS = [server('pmg-1', 'pmg', 2), server('pve-1', 'pve', 0), server('pbs-1', 'pbs', 1)]; + +afterEach(() => { + cleanup(); +}); + +describe('ProxmoxServerPicker (quick-260924-i8v)', () => { + it('zeigt je Server ein Kaestchen, sortiert nach position, mit Produktwort', () => { + render(); + + const boxes = screen.getAllByRole('checkbox'); + expect(boxes.map((b) => b.getAttribute('value'))).toEqual(['pve-1', 'pbs-1', 'pmg-1']); + expect(screen.getByRole('checkbox', { name: /pve-1.*Virtualisierung/ })).toBeInTheDocument(); + expect(screen.getByRole('checkbox', { name: /pbs-1.*Datensicherung/ })).toBeInTheDocument(); + expect(screen.getByRole('checkbox', { name: /pmg-1.*Mail-Gateway/ })).toBeInTheDocument(); + expect(screen.getByRole('group', { name: 'Angezeigte Server' })).toBeInTheDocument(); + }); + + it('ausgewaehlte Kennungen sind angehakt', () => { + render(); + + expect(screen.getByRole('checkbox', { name: /pbs-1/ })).toBeChecked(); + expect(screen.getByRole('checkbox', { name: /pve-1/ })).not.toBeChecked(); + expect(screen.getByRole('checkbox', { name: /pmg-1/ })).not.toBeChecked(); + }); + + it('Anhaken liefert die neue Liste in Listenreihenfolge', () => { + const onChange = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('checkbox', { name: /pve-1/ })); + + expect(onChange).toHaveBeenCalledWith(['pve-1', 'pmg-1']); + }); + + it('Kennungen geloeschter Server fallen bei jeder Aenderung heraus', () => { + const onChange = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole('checkbox', { name: /pmg-1/ })); + + expect(onChange).toHaveBeenCalledWith(['pbs-1', 'pmg-1']); + }); + + it('alles abhaken ergibt eine leere Liste', () => { + const onChange = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('checkbox', { name: /pve-1/ })); + + expect(onChange).toHaveBeenCalledWith([]); + }); + + it('der Hinweis „Ohne Auswahl zeigt die Kachel alle Server.“ steht sichtbar da', () => { + render(); + + expect(screen.getByText('Ohne Auswahl zeigt die Kachel alle Server.')).toBeVisible(); + }); + + it('zwei Instanzen erzeugen eindeutige Feldkennungen', () => { + render( + <> + + + , + ); + + const ids = screen.getAllByRole('checkbox').map((b) => b.id); + expect(new Set(ids).size).toBe(ids.length); + }); +}); diff --git a/apps/web/src/components/settings/proxmox-widget-config-form.test.tsx b/apps/web/src/components/settings/proxmox-widget-config-form.test.tsx new file mode 100644 index 0000000..8025435 --- /dev/null +++ b/apps/web/src/components/settings/proxmox-widget-config-form.test.tsx @@ -0,0 +1,118 @@ +import { cleanup, fireEvent, render as rtlRender, screen, waitFor } from '@testing-library/react'; +import { NextIntlClientProvider } from 'next-intl'; +import type { ReactElement } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { ProxmoxServer } from '@/lib/proxmox-api'; +import de from '@/messages/de.json'; + +/** + * Einstellungsformular der Proxmox-Kachel (quick-260924-i8v) fuer + * Einstellungen > Dashboard: Titel und Serverauswahl. + */ +function render(ui: ReactElement) { + return rtlRender( + + {ui} + , + ); +} + +// T-I8V-02: `pollServer` nur als Spion — das Formular darf ihn nie rufen. +const mockListServers = vi.fn(); +const mockPollServer = vi.fn(); +vi.mock('@/lib/proxmox-api', () => ({ + listServers: (...args: unknown[]) => mockListServers(...args), + pollServer: (...args: unknown[]) => mockPollServer(...args), +})); + +import { ProxmoxWidgetConfigForm } from './proxmox-widget-config-form'; + +function server(id: string, position: number): ProxmoxServer { + return { + id, + tenantId: 't1', + name: id, + productType: 'pve', + baseUrl: 'https://x.intern', + authMethod: 'token', + tokenId: 'a@pam!b', + username: null, + tlsRejectUnauthorized: true, + isActive: true, + pollIntervalMin: 5, + position, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + status: null, + }; +} + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe('ProxmoxWidgetConfigForm (quick-260924-i8v)', () => { + it('laedt die Server einmal und zeigt die Auswahl, vorbelegt aus der Konfiguration', async () => { + mockListServers.mockResolvedValue([server('pve-a', 0), server('pve-b', 1)]); + render( + , + ); + + expect(screen.getByText('Lade Serverliste...')).toBeInTheDocument(); + expect(await screen.findByRole('checkbox', { name: /pve-b/ })).toBeChecked(); + expect(screen.getByRole('checkbox', { name: /pve-a/ })).not.toBeChecked(); + expect((screen.getByLabelText('Titel') as HTMLInputElement).value).toBe('Rechenzentrum'); + expect(mockListServers).toHaveBeenCalledTimes(1); + }); + + it('Fehlersatz, wenn die Liste nicht geladen werden kann', async () => { + mockListServers.mockRejectedValue(new Error('500')); + render(); + + expect( + await screen.findByText('Die Serverliste konnte nicht geladen werden.'), + ).toBeInTheDocument(); + }); + + it('Leersatz ohne Server', async () => { + mockListServers.mockResolvedValue([]); + render(); + + expect(await screen.findByText('Noch kein Proxmox-Server eingetragen.')).toBeInTheDocument(); + expect(screen.queryByRole('checkbox')).toBeNull(); + }); + + it('Titelaenderung ruft onChange({ title }) mit dem rohen Tippwert', async () => { + mockListServers.mockResolvedValue([server('pve-a', 0)]); + const onChange = vi.fn(); + render(); + await screen.findByRole('checkbox'); + + fireEvent.change(screen.getByLabelText('Titel'), { target: { value: 'Keller ' } }); + + expect(onChange).toHaveBeenCalledWith({ title: 'Keller ' }); + }); + + it('Auswahlaenderung ruft onChange({ serverIds })', async () => { + mockListServers.mockResolvedValue([server('pve-a', 0), server('pve-b', 1)]); + const onChange = vi.fn(); + render(); + + fireEvent.click(await screen.findByRole('checkbox', { name: /pve-b/ })); + + expect(onChange).toHaveBeenCalledWith({ serverIds: ['pve-a', 'pve-b'] }); + }); + + it('ruft nie die manuelle Abfrage auf (T-I8V-02)', async () => { + mockListServers.mockResolvedValue([server('pve-a', 0)]); + render(); + + await screen.findByRole('checkbox'); + await waitFor(() => expect(mockListServers).toHaveBeenCalledTimes(1)); + expect(mockPollServer).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/settings/widget-settings-panel.test.tsx b/apps/web/src/components/settings/widget-settings-panel.test.tsx index c9b2f6c..cba9e9c 100644 --- a/apps/web/src/components/settings/widget-settings-panel.test.tsx +++ b/apps/web/src/components/settings/widget-settings-panel.test.tsx @@ -40,6 +40,15 @@ vi.mock('@/components/settings/search-provider-form', () => ({ SearchProviderForm: () => null, })); +// quick-260924-i8v: das Proxmox-Formular laedt die Serverliste; `pollServer` +// nur als Spion (T-I8V-02). +const mockListServers = vi.fn(); +const mockPollServer = vi.fn(); +vi.mock('@/lib/proxmox-api', () => ({ + listServers: (...args: unknown[]) => mockListServers(...args), + pollServer: (...args: unknown[]) => mockPollServer(...args), +})); + const clockTexts = (de as { widgets: { clock: Record } }).widgets.clock; const cal = (de as { widgets: { calendar: Record } }).widgets.calendar; @@ -285,3 +294,42 @@ describe('WidgetSettingsPanel — Bilderrahmen-Kopfzeile (Kosmetik nach quick-26 expect(screen.getByRole('button', { name: /Bilderrahmen #3/ })).not.toHaveTextContent('—'); }); }); + +describe('WidgetSettingsPanel — Proxmox (quick-260924-i8v)', () => { + it('Test PX1: der Zweig rendert das Proxmox-Formular, Kopfzeile zeigt "— Titel", Auswahl ruft updateWidgetConfig/onWidgetUpdate', async () => { + mockListServers.mockResolvedValue([ + { + id: 'srv-a', + name: 'pve-a', + productType: 'pve', + position: 0, + isActive: true, + status: null, + }, + ]); + const onWidgetUpdate = vi.fn(); + const widget = { id: 'px1', widgetType: 'proxmox', config: { title: 'Rechenzentrum' } }; + render(); + + const header = screen.getByRole('button', { name: /Proxmox #1/ }); + expect(header).toHaveTextContent('— Rechenzentrum'); + fireEvent.click(header); + + expect((screen.getByLabelText('Titel') as HTMLInputElement).value).toBe('Rechenzentrum'); + fireEvent.click(await screen.findByRole('checkbox', { name: /pve-a/ })); + + await vi.waitFor(() => + expect(onWidgetUpdate).toHaveBeenCalledWith('px1', { serverIds: ['srv-a'] }), + ); + expect(updateWidgetConfig).toHaveBeenCalledWith('px1', { serverIds: ['srv-a'] }); + expect(mockPollServer).not.toHaveBeenCalled(); + }); + + it('Test PX2: ohne Titel kein Zusatz in der Kopfzeile', () => { + mockListServers.mockResolvedValue([]); + const widget = { id: 'px2', widgetType: 'proxmox', config: {} }; + render(); + + expect(screen.getByRole('button', { name: /Proxmox #1/ })).not.toHaveTextContent('—'); + }); +});