b3f0e3cdcd
Befunde 1-6 der Biome-Regel useExhaustiveDependencies (quick-260921-gof): - calendar-widget.tsx: useMemo um resolveCalendarConfig() entfernt (reine Funktion, spart nichts). showToday setzt monthDate jetzt identitaetserhaltend, wenn der aktuelle Monat schon angezeigt wird - erst danach durfte der Ladeeffekt von monthDate.getTime() auf monthDate umgestellt werden, sonst haette jeder Druck auf den Monatsknopf im laufenden Monat einen Termin-Abruf bis zum Exchange-Server ausgeloest (D-04). - stopwatch-widget.tsx: neue reine Hilfsfunktion computeElapsedFrom() fuer den Takt-Effekt, der jetzt nur noch drei Einzelwerte statt des ganzen sw-Objekts liest - eine sw-Abhaengigkeit haette den 100-ms-Takt bei jeder aufgezeichneten Runde ab- und wiederaufgebaut. - Testerweiterungen als Rueckfallsicherungen: 2x weiterblaettern -> 3 Abrufe, 3x Monatsknopf im laufenden Monat -> kein Zusatzabruf; Runde waehrend die Stoppuhr laeuft unterbricht den Takt nicht, genau 1 PATCH je Klick. - Wirkungslose eslint-disable-Zeilen fuer diese Regel entfallen (kein ESLint mehr im Projekt). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
241 lines
7.6 KiB
TypeScript
241 lines
7.6 KiB
TypeScript
import { act, fireEvent, render, screen } from '@testing-library/react';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
// Mock next-intl — passthrough t(key) => key
|
|
vi.mock('next-intl', () => ({
|
|
useTranslations: () => (key: string) => key,
|
|
}));
|
|
|
|
// Mock @/lib/dashboard-api — spy on updateWidgetConfig
|
|
vi.mock('@/lib/dashboard-api', () => ({
|
|
updateWidgetConfig: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
// Import mocked function for assertions
|
|
import { updateWidgetConfig } from '@/lib/dashboard-api';
|
|
|
|
// Must import AFTER mocks
|
|
import { StopwatchWidget } from './stopwatch-widget';
|
|
|
|
describe('StopwatchWidget', () => {
|
|
beforeEach(() => {
|
|
// Use fake timers for deterministic testing
|
|
vi.useFakeTimers();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('renders 00:00 and Start button with empty config', () => {
|
|
render(<StopwatchWidget instanceId="sw-1" config={{}} isEditMode={false} />);
|
|
|
|
expect(screen.getByTestId('stopwatch-display')).toHaveTextContent('00:00');
|
|
// Button with accessible name matching the translation key 'stopwatch.start'
|
|
expect(
|
|
screen.getByRole('button', { name: /stopwatch\.start/i }),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it('clicking Start persists state=running with startedAt ISO string', async () => {
|
|
render(<StopwatchWidget instanceId="sw-1" config={{}} isEditMode={false} />);
|
|
|
|
const startBtn = screen.getByRole('button', { name: /stopwatch\.start/i });
|
|
await act(async () => {
|
|
fireEvent.click(startBtn);
|
|
});
|
|
|
|
expect(updateWidgetConfig).toHaveBeenCalledWith(
|
|
'sw-1',
|
|
expect.objectContaining({
|
|
state: 'running',
|
|
startedAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/),
|
|
}),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it('advances display after 3000ms while running', async () => {
|
|
render(<StopwatchWidget instanceId="sw-1" config={{}} isEditMode={false} />);
|
|
|
|
await act(async () => {
|
|
fireEvent.click(screen.getByRole('button', { name: /stopwatch\.start/i }));
|
|
});
|
|
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(3000);
|
|
});
|
|
|
|
const display = screen.getByTestId('stopwatch-display').textContent ?? '';
|
|
// Display should show 00:03 (or similar — just not 00:00)
|
|
expect(display).not.toBe('00:00');
|
|
});
|
|
|
|
it('Stop/Pause freezes display and persists state=paused', async () => {
|
|
render(<StopwatchWidget instanceId="sw-1" config={{}} isEditMode={false} />);
|
|
|
|
// Start the stopwatch
|
|
await act(async () => {
|
|
fireEvent.click(screen.getByRole('button', { name: /stopwatch\.start/i }));
|
|
});
|
|
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(2000);
|
|
});
|
|
|
|
// Stop it
|
|
await act(async () => {
|
|
fireEvent.click(screen.getByRole('button', { name: /stopwatch\.stop/i }));
|
|
});
|
|
|
|
expect(updateWidgetConfig).toHaveBeenLastCalledWith(
|
|
'sw-1',
|
|
expect.objectContaining({
|
|
state: 'paused',
|
|
startedAt: null,
|
|
elapsed: expect.any(Number),
|
|
}),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it('Reset returns display to 00:00 and persists state=stopped with elapsed=0 laps=[]', async () => {
|
|
render(<StopwatchWidget instanceId="sw-1" config={{}} isEditMode={false} />);
|
|
|
|
await act(async () => {
|
|
fireEvent.click(screen.getByRole('button', { name: /stopwatch\.start/i }));
|
|
});
|
|
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(1500);
|
|
});
|
|
|
|
await act(async () => {
|
|
fireEvent.click(screen.getByRole('button', { name: /stopwatch\.reset/i }));
|
|
});
|
|
|
|
expect(screen.getByTestId('stopwatch-display')).toHaveTextContent('00:00');
|
|
expect(updateWidgetConfig).toHaveBeenLastCalledWith(
|
|
'sw-1',
|
|
expect.objectContaining({
|
|
state: 'stopped',
|
|
startedAt: null,
|
|
elapsed: 0,
|
|
laps: [],
|
|
}),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it('Lap while running appends elapsed to laps list', async () => {
|
|
render(<StopwatchWidget instanceId="sw-1" config={{}} isEditMode={false} />);
|
|
|
|
await act(async () => {
|
|
fireEvent.click(screen.getByRole('button', { name: /stopwatch\.start/i }));
|
|
});
|
|
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(1000);
|
|
});
|
|
|
|
await act(async () => {
|
|
fireEvent.click(screen.getByRole('button', { name: /stopwatch\.lap/i }));
|
|
});
|
|
|
|
// At least one lap entry visible
|
|
expect(updateWidgetConfig).toHaveBeenLastCalledWith(
|
|
'sw-1',
|
|
expect.objectContaining({
|
|
laps: expect.arrayContaining([expect.any(Number)]),
|
|
}),
|
|
expect.anything(),
|
|
);
|
|
|
|
// Lap entry rendered in the DOM
|
|
const lapItems = screen.getAllByRole('listitem');
|
|
expect(lapItems.length).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
it('reload reconstruction: renders ~7000ms elapsed from config with startedAt 5s ago', async () => {
|
|
// Fix system time so Date.now() is deterministic
|
|
const fixedNow = new Date('2026-07-01T12:00:00.000Z');
|
|
vi.setSystemTime(fixedNow);
|
|
|
|
// startedAt is 5 seconds before fixedNow
|
|
const startedAt = new Date(fixedNow.getTime() - 5000).toISOString();
|
|
|
|
// Render with a running config: 2000ms already accumulated + 5s since startedAt = ~7000ms total
|
|
render(
|
|
<StopwatchWidget
|
|
instanceId="sw-reload"
|
|
config={{
|
|
state: 'running',
|
|
startedAt,
|
|
elapsed: 2000,
|
|
laps: [],
|
|
}}
|
|
isEditMode={false}
|
|
/>,
|
|
);
|
|
|
|
// Tick the interval once
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(100);
|
|
});
|
|
|
|
const display = screen.getByTestId('stopwatch-display').textContent ?? '';
|
|
// 7000ms = 7 seconds → should show 00:07
|
|
// More precisely: display must not show 00:00, 00:01, or 00:02 (which would ignore startedAt)
|
|
// Accept any display > 5s
|
|
const parts = display.split(':');
|
|
const seconds = parseInt(parts[parts.length - 1] ?? '0', 10);
|
|
const minutes = parseInt(parts[parts.length - 2] ?? '0', 10);
|
|
const totalSeconds = minutes * 60 + seconds;
|
|
expect(totalSeconds).toBeGreaterThanOrEqual(5);
|
|
});
|
|
|
|
it('quick-260921-gof: Runde unterbricht den Takt nicht — Anzeige laeuft danach weiter statt zurueckzufallen, und pro Klick geht genau ein PATCH (Rueckfallsicherung gegen sw als Effekt-Abhaengigkeit)', async () => {
|
|
render(<StopwatchWidget instanceId="sw-lap-tick" config={{}} isEditMode={false} />);
|
|
|
|
await act(async () => {
|
|
fireEvent.click(screen.getByRole('button', { name: /stopwatch\.start/i }));
|
|
});
|
|
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(2000);
|
|
});
|
|
|
|
const beforeLap = screen.getByTestId('stopwatch-display').textContent;
|
|
|
|
await act(async () => {
|
|
fireEvent.click(screen.getByRole('button', { name: /stopwatch\.lap/i }));
|
|
});
|
|
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(2000);
|
|
});
|
|
|
|
const afterLap = screen.getByTestId('stopwatch-display').textContent;
|
|
|
|
// Anzeige muss ueber die Runde hinweg weiterlaufen, nicht auf 00:00
|
|
// zurueckfallen und nicht stehen bleiben.
|
|
expect(afterLap).not.toBe('00:00');
|
|
expect(afterLap).not.toBe(beforeLap);
|
|
|
|
// Genau ein PATCH je Klick: Start + Runde = 2, kein Dauerfeuer durch ein
|
|
// Ab- und Wiederaufbauen des Takt-Intervalls beim Aufzeichnen der Runde.
|
|
expect(updateWidgetConfig).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('quick-260916-bwo: die Anzeige skaliert per Container-Query-Klasse, keine Fensterbreiten-Formel im Style', () => {
|
|
render(<StopwatchWidget instanceId="sw-cq" config={{}} isEditMode={false} />);
|
|
|
|
const display = screen.getByTestId('stopwatch-display');
|
|
expect(display.className).toMatch(/cqw/);
|
|
expect(display.className).toMatch(/cqh/);
|
|
expect(display.style.fontSize).toBe('');
|
|
});
|
|
});
|