import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import type { Activity, WorkSession } from '@solelog/shared'; import Stopwatch from './Stopwatch'; import { useActivities } from '../api/activities'; import { useActiveSessions, useStartSession, useStopSession, useDiscardSession, usePauseSession, useResumeSession, } from '../api/sessions'; vi.mock('../api/activities', () => ({ useActivities: vi.fn(), })); vi.mock('../api/sessions', () => ({ useActiveSessions: vi.fn(), useStartSession: vi.fn(), useStopSession: vi.fn(), useDiscardSession: vi.fn(), usePauseSession: vi.fn(), useResumeSession: vi.fn(), })); const mockedUseActivities = vi.mocked(useActivities); const mockedUseActiveSessions = vi.mocked(useActiveSessions); const mockedUseStartSession = vi.mocked(useStartSession); const mockedUseStopSession = vi.mocked(useStopSession); const mockedUseDiscardSession = vi.mocked(useDiscardSession); const mockedUsePauseSession = vi.mocked(usePauseSession); const mockedUseResumeSession = vi.mocked(useResumeSession); const FREZEN: Activity = { id: 1, name: 'Frezen', insole_types: ['Kurk', 'Berk'], sort_order: 0, created_at: '2026-01-01T00:00:00.000Z', }; const PRINTEN: Activity = { id: 2, name: 'Printen', insole_types: ['3D'], sort_order: 1, created_at: '2026-01-01T00:00:00.000Z', }; function activeSession(overrides: Partial = {}): WorkSession { return { id: 99, user_id: 'u1', activity_id: 1, activity_name: 'Frezen', insole_type: 'Kurk', pair_count: 2, start_time: new Date().toISOString(), end_time: null, duration_seconds: null, paused_seconds: 0, paused_at: null, status: 'active', source: 'app', notes: null, created_at: new Date().toISOString(), ...overrides, }; } // Build a fake RQ query/mutation result object the screen can consume. function query(data: unknown): R { return { data, isLoading: false, isError: false } as unknown as R; } let startMutate: ReturnType; let stopMutate: ReturnType; let discardMutate: ReturnType; let pauseMutate: ReturnType; let resumeMutate: ReturnType; function mutation(mutate: ReturnType): R { return { mutate, isPending: false } as unknown as R; } function renderStopwatch() { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return render( ); } describe('Stopwatch', () => { beforeEach(() => { startMutate = vi.fn(); stopMutate = vi.fn(); discardMutate = vi.fn(); pauseMutate = vi.fn(); resumeMutate = vi.fn(); mockedUseActivities.mockReturnValue(query>([FREZEN, PRINTEN])); mockedUseActiveSessions.mockReturnValue(query>([])); mockedUseStartSession.mockReturnValue( mutation>(startMutate) ); mockedUseStopSession.mockReturnValue(mutation>(stopMutate)); mockedUseDiscardSession.mockReturnValue( mutation>(discardMutate) ); mockedUsePauseSession.mockReturnValue( mutation>(pauseMutate) ); mockedUseResumeSession.mockReturnValue( mutation>(resumeMutate) ); }); afterEach(() => { vi.clearAllMocks(); vi.useRealTimers(); }); it('renders the three sections and Start button in Dutch', () => { renderStopwatch(); expect(screen.getByText('Type zool')).toBeInTheDocument(); expect(screen.getByText('Type handeling')).toBeInTheDocument(); expect(screen.getByText('Aantal zolen')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Start Stopwatch' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Kurk' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Berk' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: '3D' })).toBeInTheDocument(); // Default count is 2 (free-typed text mirror, so the value is the string '2'). expect(screen.getByLabelText('Aantal zolen')).toHaveValue('2'); }); it('disables Start until a handling is chosen', async () => { const user = userEvent.setup(); renderStopwatch(); const startBtn = screen.getByRole('button', { name: 'Start Stopwatch' }); expect(startBtn).toBeDisabled(); // Kurk is the default zooltype; pick the Frezen handling. await user.selectOptions(screen.getByLabelText('Type handeling'), 'Frezen'); await waitFor(() => expect(startBtn).toBeEnabled()); }); it('filters handlings by the chosen zooltype', async () => { const user = userEvent.setup(); renderStopwatch(); const select = screen.getByLabelText('Type handeling'); // Kurk selected by default -> Frezen present, Printen absent. expect(screen.getByRole('option', { name: 'Frezen' })).toBeInTheDocument(); expect(screen.queryByRole('option', { name: 'Printen' })).not.toBeInTheDocument(); // Switch to 3D -> Printen present, Frezen absent. await user.click(screen.getByRole('button', { name: '3D' })); expect(screen.getByRole('option', { name: 'Printen' })).toBeInTheDocument(); expect(screen.queryByRole('option', { name: 'Frezen' })).not.toBeInTheDocument(); expect(select).toBeInTheDocument(); }); it('calls start with the selected values', async () => { const user = userEvent.setup(); renderStopwatch(); // Pick Berk; Frezen applies to Berk. await user.click(screen.getByRole('button', { name: 'Berk' })); await user.selectOptions(screen.getByLabelText('Type handeling'), 'Frezen'); // Bump count to 3. await user.click(screen.getByRole('button', { name: 'Meer zolen' })); await user.click(screen.getByRole('button', { name: 'Start Stopwatch' })); expect(startMutate).toHaveBeenCalledTimes(1); expect(startMutate.mock.calls[0][0]).toMatchObject({ activity_id: 1, insole_type: 'Berk', pair_count: 3, }); }); it('arms discard on first Annuleren tap and discards on the second', async () => { const user = userEvent.setup(); // Recover an active session so the screen renders the running UI. mockedUseActiveSessions.mockReturnValue( query>([activeSession()]) ); renderStopwatch(); const cancel = await screen.findByRole('button', { name: 'Annuleren' }); await user.click(cancel); expect( screen.getByRole('button', { name: 'Nogmaals tikken ter bevestiging' }) ).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Nogmaals tikken ter bevestiging' })); expect(discardMutate).toHaveBeenCalledTimes(1); expect(discardMutate.mock.calls[0][0]).toBe(99); }); it('pauses via the server when the display is tapped while running', async () => { const user = userEvent.setup(); mockedUseActiveSessions.mockReturnValue( query>([activeSession()]) ); renderStopwatch(); const display = await screen.findByRole('button', { name: 'Stopwatch' }); await user.click(display); expect(pauseMutate).toHaveBeenCalledTimes(1); expect(pauseMutate.mock.calls[0][0]).toBe(99); expect(resumeMutate).not.toHaveBeenCalled(); }); it('resumes via the server when the display is tapped while paused', async () => { const user = userEvent.setup(); mockedUseActiveSessions.mockReturnValue( query>([ activeSession({ paused_at: new Date().toISOString(), paused_seconds: 30 }), ]) ); renderStopwatch(); const display = await screen.findByRole('button', { name: 'Stopwatch' }); await user.click(display); expect(resumeMutate).toHaveBeenCalledTimes(1); expect(resumeMutate.mock.calls[0][0]).toBe(99); expect(pauseMutate).not.toHaveBeenCalled(); }); it('recovers a paused session into the paused state on load', async () => { mockedUseActiveSessions.mockReturnValue( query>([ activeSession({ paused_at: new Date().toISOString(), paused_seconds: 30 }), ]) ); renderStopwatch(); expect(await screen.findByText('Gepauzeerd — tik om te hervatten')).toBeInTheDocument(); }); });