From d8d008b2c4558603055286963707af7624b44052 Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 1 Jul 2026 10:08:44 +0200 Subject: [PATCH] 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) --- .../widgets/stopwatch-widget.test.tsx | 198 ++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 apps/web/src/components/dashboard/widgets/stopwatch-widget.test.tsx diff --git a/apps/web/src/components/dashboard/widgets/stopwatch-widget.test.tsx b/apps/web/src/components/dashboard/widgets/stopwatch-widget.test.tsx new file mode 100644 index 0000000..a58a3f8 --- /dev/null +++ b/apps/web/src/components/dashboard/widgets/stopwatch-widget.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + // 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(); + + 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(); + + 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( + , + ); + + // 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); + }); +});