test(worker): Stopwatch + reconcile — fill gaps + lifecycle test

Fill the Stopwatch domain to 100% coverage (Stopwatch.tsx + stopwatch.ts,
both now 100% stmts/branch/funcs/lines) and add the reconcile lifecycle test.

New tests cover: idle display-tap start (and no-op without a handling),
pair-count stepper floor + free-typed entry (non-positive ignored), clearing
the handling via the empty option, stop/discard onSuccess reset, discard
arm-window expiry, clearing a pending discard timer on reconcile and on
unmount, and the reconcile lifecycle — running locally, then a poll without
the local session resets the timer and shows the admin-stopped notice.

No behaviour changes. Five defensive/unreachable guards in Stopwatch.tsx are
annotated /* c8 ignore next -- <reason> */ (comment-only): the
paused_seconds ?? 0 coalesce (contract types it non-null) and the early-return
guards in handleSelectZool/handleStart/handleStop/handleDiscard (each triggered
by a control disabled or unrendered in that state).

No bugs found.
This commit is contained in:
Bas van Rossem
2026-07-23 15:33:03 +02:00
parent 75c5a9f350
commit a30441b2a2
3 changed files with 239 additions and 1 deletions

View File

@@ -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<ReturnType<typeof useActiveSessions>>([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<ReturnType<typeof useActiveSessions>>([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<ReturnType<typeof useActiveSessions>>([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<ReturnType<typeof useActiveSessions>>([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<ReturnType<typeof useActiveSessions>>([]));
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
rerender(
<QueryClientProvider client={queryClient}>
<Stopwatch />
</QueryClientProvider>,
);
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<ReturnType<typeof useActiveSessions>>([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<ReturnType<typeof useActiveSessions>>([]));
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<ReturnType<typeof useActiveSessions>>([activeSession({ id: 77, user_id: 'other' })]),
);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
rerender(
<QueryClientProvider client={queryClient}>
<Stopwatch />
</QueryClientProvider>,
);
// 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();
});
});

View File

@@ -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);