import { act, fireEvent, 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 { ApiError } from '../lib/api'; 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(); }); it('resets and shows a notice when the running session is stopped by the admin', async () => { // Start with the session present so the screen adopts the running state. mockedUseActiveSessions.mockReturnValue( query>([activeSession()]), ); const { rerender } = renderStopwatch(); // Confirm we are running (the Stop & Opslaan button is shown). await screen.findByRole('button', { name: 'Stop & Opslaan' }); // The admin stops the session: the active list now no longer contains it. mockedUseActiveSessions.mockReturnValue(query>([])); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); rerender( , ); // The stopwatch resets to idle and shows the admin-stopped notice. expect( await screen.findByText('Deze sessie is door de beheerder gestopt.'), ).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Start Stopwatch' })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Stop & Opslaan' })).not.toBeInTheDocument(); }); it('resets locally when stop fails with a 409 (already closed)', async () => { const user = userEvent.setup(); stopMutate.mockImplementation((_id: number, opts?: { onError?: (e: unknown) => void }) => { opts?.onError?.(new ApiError(409, 'Conflict')); }); mockedUseActiveSessions.mockReturnValue( query>([activeSession()]), ); renderStopwatch(); const stopBtn = await screen.findByRole('button', { name: 'Stop & Opslaan' }); await user.click(stopBtn); expect(stopMutate).toHaveBeenCalledTimes(1); // The timer resets to idle despite the 409 (no stuck running state). await waitFor(() => expect(screen.getByRole('button', { name: 'Start Stopwatch' })).toBeInTheDocument(), ); expect(screen.queryByRole('button', { name: 'Stop & Opslaan' })).not.toBeInTheDocument(); }); it('starts by tapping the display when idle and a handling is chosen', async () => { const user = userEvent.setup(); renderStopwatch(); await user.selectOptions(screen.getByLabelText('Type handeling'), 'Frezen'); await user.click(screen.getByRole('button', { name: 'Stopwatch' })); expect(startMutate).toHaveBeenCalledTimes(1); }); it('does not start on a display tap when idle with no handling chosen', async () => { const user = userEvent.setup(); renderStopwatch(); await user.click(screen.getByRole('button', { name: 'Stopwatch' })); expect(startMutate).not.toHaveBeenCalled(); }); it('decrements the pair count and disables the minus button at the floor of 1', async () => { const user = userEvent.setup(); renderStopwatch(); const input = screen.getByLabelText('Aantal zolen'); expect(input).toHaveValue('2'); await user.click(screen.getByRole('button', { name: 'Minder zolen' })); expect(input).toHaveValue('1'); expect(screen.getByRole('button', { name: 'Minder zolen' })).toBeDisabled(); }); it('accepts a free-typed pair count and ignores a non-positive value', async () => { const user = userEvent.setup(); renderStopwatch(); const input = screen.getByLabelText('Aantal zolen'); await user.clear(input); await user.type(input, '7'); expect(input).toHaveValue('7'); // A non-positive entry is mirrored in the field but is not adopted as the count. await user.clear(input); await user.type(input, '0'); expect(input).toHaveValue('0'); // Starting uses the last valid count (7), proving the '0' was ignored. await user.selectOptions(screen.getByLabelText('Type handeling'), 'Frezen'); await user.click(screen.getByRole('button', { name: 'Start Stopwatch' })); expect(startMutate.mock.calls[0][0]).toMatchObject({ pair_count: 7 }); }); it('clears the chosen handling when the empty option is reselected', async () => { const user = userEvent.setup(); renderStopwatch(); const select = screen.getByLabelText('Type handeling'); await user.selectOptions(select, 'Frezen'); expect(screen.getByRole('button', { name: 'Start Stopwatch' })).toBeEnabled(); await user.selectOptions(select, ''); expect(screen.getByRole('button', { name: 'Start Stopwatch' })).toBeDisabled(); }); it('resets to idle when stop succeeds', async () => { const user = userEvent.setup(); stopMutate.mockImplementation((_id: number, opts?: { onSuccess?: () => void }) => { opts?.onSuccess?.(); }); mockedUseActiveSessions.mockReturnValue( query>([activeSession()]), ); renderStopwatch(); await user.click(await screen.findByRole('button', { name: 'Stop & Opslaan' })); expect(await screen.findByRole('button', { name: 'Start Stopwatch' })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Stop & Opslaan' })).not.toBeInTheDocument(); }); it('resets to idle when discard is confirmed and succeeds', async () => { const user = userEvent.setup(); discardMutate.mockImplementation((_id: number, opts?: { onSuccess?: () => void }) => { opts?.onSuccess?.(); }); mockedUseActiveSessions.mockReturnValue( query>([activeSession()]), ); renderStopwatch(); await user.click(await screen.findByRole('button', { name: 'Annuleren' })); await user.click(screen.getByRole('button', { name: 'Nogmaals tikken ter bevestiging' })); expect(discardMutate).toHaveBeenCalledTimes(1); expect(await screen.findByRole('button', { name: 'Start Stopwatch' })).toBeInTheDocument(); }); it('disarms discard after the confirm window expires', () => { // Fake timers + fireEvent (userEvent's internal delays fight the 1s display tick). vi.useFakeTimers(); mockedUseActiveSessions.mockReturnValue( query>([activeSession()]), ); renderStopwatch(); fireEvent.click(screen.getByRole('button', { name: 'Annuleren' })); expect( screen.getByRole('button', { name: 'Nogmaals tikken ter bevestiging' }), ).toBeInTheDocument(); // The 3s arm window lapses without a second tap → discard disarms back to 'Annuleren'. act(() => { vi.advanceTimersByTime(3000); }); expect(screen.getByRole('button', { name: 'Annuleren' })).toBeInTheDocument(); }); it('clears a pending discard timer when the session is reconciled away', async () => { const user = userEvent.setup(); mockedUseActiveSessions.mockReturnValue( query>([activeSession()]), ); const { rerender } = renderStopwatch(); // Arm discard so a pending timer exists when the reconcile reset fires. await user.click(await screen.findByRole('button', { name: 'Annuleren' })); await screen.findByRole('button', { name: 'Nogmaals tikken ter bevestiging' }); // Admin stops the session: the poll no longer lists it → resetTimer clears the armed timer. mockedUseActiveSessions.mockReturnValue(query>([])); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); rerender( , ); expect( await screen.findByText('Deze sessie is door de beheerder gestopt.'), ).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Start Stopwatch' })).toBeInTheDocument(); }); it('clears a pending discard timer on unmount', async () => { const user = userEvent.setup(); const clearSpy = vi.spyOn(globalThis, 'clearTimeout'); mockedUseActiveSessions.mockReturnValue( query>([activeSession()]), ); const { unmount } = renderStopwatch(); await user.click(await screen.findByRole('button', { name: 'Annuleren' })); clearSpy.mockClear(); unmount(); expect(clearSpy).toHaveBeenCalled(); }); it('lifecycle: running locally, then a poll without the session resets it and shows the admin-stopped notice', async () => { const user = userEvent.setup(); const local = activeSession({ id: 42 }); startMutate.mockImplementation( (_input: unknown, opts?: { onSuccess?: (s: WorkSession) => void }) => { opts?.onSuccess?.(local); }, ); // Idle to begin with (no server-active session to adopt). mockedUseActiveSessions.mockReturnValue(query>([])); const { rerender } = renderStopwatch(); // Start locally → the screen enters the running state from the start mutation's onSuccess. await user.selectOptions(screen.getByLabelText('Type handeling'), 'Frezen'); await user.click(screen.getByRole('button', { name: 'Start Stopwatch' })); await screen.findByRole('button', { name: 'Stop & Opslaan' }); // The active-sessions poll now returns a list WITHOUT our session (only another worker's). mockedUseActiveSessions.mockReturnValue( query>([activeSession({ id: 77, user_id: 'other' })]), ); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); rerender( , ); // The timer resets to idle and the admin-stopped notice shows. expect( await screen.findByText('Deze sessie is door de beheerder gestopt.'), ).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Start Stopwatch' })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Stop & Opslaan' })).not.toBeInTheDocument(); }); });