test(08-02): add failing tests for Stopwatch behavior and reload reconstruction (RED)

- Tests for start/stop/reset/lap controls
- Reload reconstruction test verifies elapsed from startedAt + stored elapsed
- Tests fail because stopwatch-widget.tsx does not yet exist (expected RED state)
This commit is contained in:
2026-07-01 10:08:44 +02:00
parent 2fa5193fd0
commit d8d008b2c4
@@ -0,0 +1,198 @@
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);
});
});