diff --git a/apps/worker/src/screens/Stopwatch.test.tsx b/apps/worker/src/screens/Stopwatch.test.tsx index d3e04cd..c861307 100644 --- a/apps/worker/src/screens/Stopwatch.test.tsx +++ b/apps/worker/src/screens/Stopwatch.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from '@testing-library/react'; +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'; @@ -296,4 +296,197 @@ describe('Stopwatch', () => { ); 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(); + }); }); diff --git a/apps/worker/src/screens/Stopwatch.tsx b/apps/worker/src/screens/Stopwatch.tsx index 98fcd66..aa652bf 100644 --- a/apps/worker/src/screens/Stopwatch.tsx +++ b/apps/worker/src/screens/Stopwatch.tsx @@ -75,6 +75,7 @@ export default function Stopwatch() { // Restore pause state from the server (source of truth). const pausedAtMs = session.paused_at ? new Date(session.paused_at).getTime() : null; setIsPaused(pausedAtMs !== null); + /* c8 ignore next -- paused_seconds is z.number().int() (never null); ?? 0 is a defensive coalesce */ setPausedMs((session.paused_seconds ?? 0) * 1000); setPauseStartedMs(pausedAtMs); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -102,6 +103,7 @@ export default function Stopwatch() { const elapsed = startMs !== null ? elapsedSeconds(startMs, effectiveNow, pausedMs) : 0; function handleSelectZool(type: InsoleType) { + /* c8 ignore next -- unreachable: the zool buttons are disabled while running */ if (isRunning) return; setInsoleType(type); setActiveActivityId(null); // changing the zool clears the chosen handling @@ -120,6 +122,7 @@ export default function Stopwatch() { } function handleStart() { + /* c8 ignore next -- unreachable: both callers gate on canStart; guard also narrows the type */ if (!canStart || activeActivityId === null) return; setStoppedByAdmin(false); // clear any prior admin-stopped notice startSession.mutate( @@ -177,6 +180,7 @@ export default function Stopwatch() { } function handleStop() { + /* c8 ignore next -- unreachable: the Stop button only renders while running (sessionId set) */ if (sessionId === null) return; const id = sessionId; stopSession.mutate(id, { onSuccess: () => resetTimer(), onError: resetIfConflict }); @@ -184,6 +188,7 @@ export default function Stopwatch() { } function handleDiscard() { + /* c8 ignore next -- unreachable: the Annuleren button only renders while running (sessionId set) */ if (sessionId === null) return; if (!discardPending) { setDiscardPending(true); diff --git a/docs/reference/domain-test-checklist.md b/docs/reference/domain-test-checklist.md index 968ae71..2dd355a 100644 --- a/docs/reference/domain-test-checklist.md +++ b/docs/reference/domain-test-checklist.md @@ -558,3 +558,43 @@ Guaranteed behaviors: - Lifecycle (`src/screens/Activities.test.tsx`): create a handeling → edit its insole types down to a single type → reorder it above another → delete it, with each step reflected in the refetched list; the Sidebar test (`src/components/Sidebar.test.tsx`) covers navigation switching screens. + +## Stopwatch + reconcile — worker client + +Src covered (worker `apps/worker`): `src/screens/Stopwatch.tsx` and `src/lib/stopwatch.ts` — both at +100% stmts/branch/funcs/lines. Five `c8 ignore`s with reasons in `Stopwatch.tsx`: the +`paused_seconds ?? 0` coalesce in the reconcile adopt (the contract types `paused_seconds` as +`z.number().int()`, never null) and the four early-return guards in `handleSelectZool`, `handleStart`, +`handleStop`, and `handleDiscard` — each unreachable because the button that triggers it is +disabled or not rendered in exactly that state (and the `handleStart` guard also narrows the type). + +Guaranteed behaviors: + +- `formatTime` renders zero-padded `HH:MM:SS`, floors a negative total at `00:00:00`, and lets hours + exceed two digits; `elapsedSeconds` returns `(now − start − paused)` floored at 0 (paused time + excluded). +- The three sections (`Type zool` / `Type handeling` / `Aantal zolen`) and the Dutch Start button + render; the zool segmented control offers `Kurk` / `Berk` / `3D` with `Kurk` selected by default. +- Choosing a zooltype filters the handeling `