test(260924-i8v): RED fuer Serverliste, Kennzahlen, Auswahl und Minutentakt der Proxmox-Kachel

- Modell: resolveProxmoxWidgetConfig, selectServers, widgetKeyFigure
- formatPercent/formatCount gemeinsam in proxmox-status
- Kachel: Sortierung, Kennzahltexte, unbekannt statt 0, Filter, Links, Titel, Takt

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-24 13:26:40 +02:00
parent a906c6705d
commit 92bf1307f6
3 changed files with 594 additions and 1 deletions
@@ -1,4 +1,4 @@
import { cleanup, render as rtlRender, screen, waitFor } from '@testing-library/react';
import { act, cleanup, 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';
@@ -231,3 +231,293 @@ describe('ProxmoxWidget: Lade-, Leer- und Fehlerzustand (quick-260924-i8v)', ()
expect(mockPollServer).not.toHaveBeenCalled();
});
});
function rowNames(): string[] {
return screen
.getAllByTestId('proxmox-row')
.map((row) => row.getAttribute('data-server-name') ?? '');
}
describe('ProxmoxWidget: Serverliste mit Kennzahl (quick-260924-i8v, Aufgabe 2)', () => {
it('Zeilen stehen in der Reihenfolge down, warn, ok, idle, orphan', async () => {
mockListServers.mockResolvedValue([
makeServer('orphan', { position: 0 }),
makeServer('ok', { position: 1 }),
makeServer('idle', { position: 2 }),
makeServer('warn', { position: 3 }),
makeServer('down', { position: 4 }),
]);
await renderWidget();
await screen.findByTestId('proxmox-summary');
expect(rowNames()).toEqual([
'server-down',
'server-warn',
'server-ok',
'server-idle',
'server-orphan',
]);
});
it('zeigt je Zeile genau eine Kennzahl in Worten', async () => {
const backupAt = Math.floor(Date.now() / 1000) - 5 * 3600;
mockListServers.mockResolvedValue([
makeServer('ok', { id: 'pve', name: 'pve-ok' }),
makeServer('warn', { id: 'pve-w', name: 'pve-warn' }),
makeServer('ok', {
id: 'pbs',
name: 'pbs-ok',
productType: 'pbs',
metrics: {
productType: 'pbs',
datastores: [
{
name: 'ds',
total: 100,
used: 10,
free: 90,
lastBackupAt: backupAt,
lastVerifyState: 'ok',
},
],
},
}),
makeServer('ok', {
id: 'pmg',
name: 'pmg-ok',
productType: 'pmg',
metrics: {
productType: 'pmg',
countIn: 12904,
countOut: 10,
spamCount: 2,
virusCount: 0,
},
}),
makeServer('down'),
makeServer('orphan'),
makeServer('idle'),
]);
await renderWidget();
await screen.findByTestId('proxmox-summary');
const figure = (name: string) =>
within(
screen
.getAllByTestId('proxmox-row')
.find((row) => row.getAttribute('data-server-name') === name) as HTMLElement,
).getByTestId('proxmox-key-figure');
expect(figure('pve-ok')).toHaveTextContent('3/4 Gäste laufen');
expect(figure('pve-warn').textContent).toMatch(/^Auslastung 87\s%$/);
expect(figure('pve-warn')).toHaveClass('text-status-warn-fg');
expect(figure('pbs-ok')).toHaveTextContent('Sicherung vor 5 Std.');
expect(figure('pmg-ok')).toHaveTextContent('12.904 eingehend');
expect(figure('server-down')).toHaveTextContent('nicht erreichbar');
expect(figure('server-down')).toHaveClass('text-status-down-fg');
expect(figure('server-down')).toHaveAttribute('aria-hidden', 'true');
expect(figure('server-orphan')).toHaveTextContent('offline & verwaist');
expect(figure('server-idle')).toHaveTextContent('noch nicht abgefragt');
});
it('unbekannte Werte heissen „unbekannt“ und nie 0', async () => {
mockListServers.mockResolvedValue([
makeServer('ok', { id: 'pve', name: 'pve-leer', metrics: null }),
makeServer('ok', {
id: 'pmg',
name: 'pmg-leer',
productType: 'pmg',
metrics: {
productType: 'pmg',
countIn: null,
countOut: null,
spamCount: null,
virusCount: null,
},
}),
]);
await renderWidget();
await screen.findByTestId('proxmox-summary');
const figures = screen.getAllByTestId('proxmox-key-figure');
expect(figures.map((f) => f.textContent)).toEqual(['unbekannt', 'unbekannt']);
expect(screen.queryByText(/0 eingehend/)).toBeNull();
expect(screen.queryByText(/0\s%/)).toBeNull();
});
it('jede Zeile nennt den Zustand auch fuer Vorleser (nicht nur Farbe)', async () => {
mockListServers.mockResolvedValue([makeServer('warn')]);
await renderWidget();
await screen.findByTestId('proxmox-summary');
expect(screen.getByText('Warnung')).toHaveClass('sr-only');
});
it('Kennzahl, Liste und Name tragen die Klassen der Groessenstufen', async () => {
mockListServers.mockResolvedValue([makeServer('ok'), makeServer('orphan')]);
await renderWidget();
await screen.findByTestId('proxmox-summary');
const list = screen.getByTestId('proxmox-list');
expect(list.tagName).toBe('UL');
expect(list).toHaveClass('[@container(max-height:7.5rem)]:hidden');
expect(list).toHaveClass('@max-[8rem]:hidden');
for (const figure of screen.getAllByTestId('proxmox-key-figure')) {
expect(figure).toHaveClass('@max-[15rem]:hidden');
}
// verwaist: nur der Name wird gedaempft
expect(screen.getByText('server-orphan')).toHaveClass('opacity-70');
expect(screen.getByText('server-ok')).not.toHaveClass('opacity-70');
});
});
describe('ProxmoxWidget: Serverauswahl, Titel, Links (quick-260924-i8v, Aufgabe 2)', () => {
it('config.serverIds beschraenkt Zeilen UND Balken/Zusammenfassung auf die Auswahl', async () => {
mockListServers.mockResolvedValue([
makeServer('ok', { id: 'a', name: 'pve-a' }),
makeServer('down', { id: 'b', name: 'pve-b' }),
]);
await renderWidget({ config: { serverIds: ['a'] } });
const summary = await screen.findByTestId('proxmox-summary');
expect(summary).toHaveTextContent('Alles in Ordnung');
expect(rowNames()).toEqual(['pve-a']);
expect(screen.getByTestId('health-bar').querySelectorAll('[data-segment]')).toHaveLength(1);
});
it('nur geloeschte Kennungen ausgewaehlt: Satz statt Balken und Liste', async () => {
mockListServers.mockResolvedValue([makeServer('ok', { id: 'a' })]);
await renderWidget({ config: { serverIds: ['weg'] } });
expect(
await screen.findByText(
'Die ausgewählten Server gibt es nicht mehr. Wählen Sie im Bearbeitungsmodus andere aus.',
),
).toBeInTheDocument();
expect(screen.queryByTestId('health-bar')).toBeNull();
expect(screen.queryByTestId('proxmox-list')).toBeNull();
});
it('Ansichtsmodus: jede Zeile ist ein Link auf /modules/proxmox', async () => {
mockListServers.mockResolvedValue([makeServer('ok'), makeServer('down')]);
await renderWidget();
await screen.findByTestId('proxmox-summary');
const links = screen.getAllByRole('link');
expect(links).toHaveLength(2);
for (const link of links) expect(link).toHaveAttribute('href', '/modules/proxmox');
});
it('Bearbeitungsmodus: keine Links in der Kachel', async () => {
mockListServers.mockResolvedValue([makeServer('ok'), makeServer('down')]);
await renderWidget({ isEditMode: true });
await screen.findByTestId('proxmox-summary');
expect(screen.getAllByTestId('proxmox-row')).toHaveLength(2);
expect(screen.queryByRole('link')).toBeNull();
});
it('Titel gesetzt: Ueberschrift h2', async () => {
mockListServers.mockResolvedValue([makeServer('ok')]);
await renderWidget({ config: { title: 'Rechenzentrum' } });
expect(await screen.findByRole('heading', { level: 2, name: 'Rechenzentrum' })).toBeVisible();
});
it('Titel leer: keine Kopfzeile', async () => {
mockListServers.mockResolvedValue([makeServer('ok')]);
await renderWidget({ config: { title: ' ' } });
await screen.findByTestId('proxmox-summary');
expect(screen.queryByRole('heading')).toBeNull();
});
});
describe('ProxmoxWidget: Minutentakt (quick-260924-i8v, Aufgabe 2)', () => {
let visibility: DocumentVisibilityState = 'visible';
beforeEach(() => {
visibility = 'visible';
Object.defineProperty(document, 'visibilityState', {
configurable: true,
get: () => visibility,
});
// Nur die Intervall-Zeitgeber faelschen: die Warte-Helfer der Testing
// Library laufen weiter ueber echte Zeitgeber.
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] });
});
afterEach(() => {
vi.useRealTimers();
// Eigene Eigenschaft entfernen, damit wieder die von Document.prototype gilt.
delete (document as unknown as Record<string, unknown>).visibilityState;
});
async function flush() {
await act(async () => {
await Promise.resolve();
});
}
async function advance(ms: number) {
await act(async () => {
await vi.advanceTimersByTimeAsync(ms);
});
}
it('laedt nach 60 s erneut aus dem Zwischenlager', async () => {
mockListServers.mockResolvedValue([makeServer('ok')]);
await renderWidget();
await flush();
expect(mockListServers).toHaveBeenCalledTimes(1);
await advance(60_000);
expect(mockListServers).toHaveBeenCalledTimes(2);
expect(mockPollServer).not.toHaveBeenCalled();
});
it('pausiert bei verborgenem Tab und laedt sofort, sobald er wieder sichtbar ist', async () => {
mockListServers.mockResolvedValue([makeServer('ok')]);
await renderWidget();
await flush();
expect(mockListServers).toHaveBeenCalledTimes(1);
visibility = 'hidden';
await advance(120_000);
expect(mockListServers).toHaveBeenCalledTimes(1);
visibility = 'visible';
await act(async () => {
document.dispatchEvent(new Event('visibilitychange'));
});
expect(mockListServers).toHaveBeenCalledTimes(2);
expect(mockPollServer).not.toHaveBeenCalled();
});
it('nach dem Aushaengen keine weiteren Abrufe', async () => {
mockListServers.mockResolvedValue([makeServer('ok')]);
const { unmount } = await renderWidget();
await flush();
unmount();
await advance(180_000);
document.dispatchEvent(new Event('visibilitychange'));
expect(mockListServers).toHaveBeenCalledTimes(1);
expect(mockPollServer).not.toHaveBeenCalled();
});
it('scheitert ein spaeteres Nachladen, bleibt die letzte Liste stehen', async () => {
mockListServers.mockResolvedValueOnce([makeServer('down')]);
await renderWidget();
await flush();
expect(await screen.findByTestId('proxmox-summary')).toHaveTextContent('1 nicht erreichbar');
mockListServers.mockRejectedValueOnce(new Error('500'));
await advance(60_000);
await flush();
expect(mockListServers).toHaveBeenCalledTimes(2);
expect(screen.getByTestId('proxmox-summary')).toHaveTextContent('1 nicht erreichbar');
expect(screen.queryByText('Die Serverliste konnte nicht geladen werden.')).toBeNull();
});
});