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();
});
});