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:
@@ -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 userEvent from '@testing-library/user-event';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
@@ -296,4 +296,197 @@ describe('Stopwatch', () => {
|
|||||||
);
|
);
|
||||||
expect(screen.queryByRole('button', { name: 'Stop & Opslaan' })).not.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<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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export default function Stopwatch() {
|
|||||||
// Restore pause state from the server (source of truth).
|
// Restore pause state from the server (source of truth).
|
||||||
const pausedAtMs = session.paused_at ? new Date(session.paused_at).getTime() : null;
|
const pausedAtMs = session.paused_at ? new Date(session.paused_at).getTime() : null;
|
||||||
setIsPaused(pausedAtMs !== 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);
|
setPausedMs((session.paused_seconds ?? 0) * 1000);
|
||||||
setPauseStartedMs(pausedAtMs);
|
setPauseStartedMs(pausedAtMs);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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;
|
const elapsed = startMs !== null ? elapsedSeconds(startMs, effectiveNow, pausedMs) : 0;
|
||||||
|
|
||||||
function handleSelectZool(type: InsoleType) {
|
function handleSelectZool(type: InsoleType) {
|
||||||
|
/* c8 ignore next -- unreachable: the zool buttons are disabled while running */
|
||||||
if (isRunning) return;
|
if (isRunning) return;
|
||||||
setInsoleType(type);
|
setInsoleType(type);
|
||||||
setActiveActivityId(null); // changing the zool clears the chosen handling
|
setActiveActivityId(null); // changing the zool clears the chosen handling
|
||||||
@@ -120,6 +122,7 @@ export default function Stopwatch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleStart() {
|
function handleStart() {
|
||||||
|
/* c8 ignore next -- unreachable: both callers gate on canStart; guard also narrows the type */
|
||||||
if (!canStart || activeActivityId === null) return;
|
if (!canStart || activeActivityId === null) return;
|
||||||
setStoppedByAdmin(false); // clear any prior admin-stopped notice
|
setStoppedByAdmin(false); // clear any prior admin-stopped notice
|
||||||
startSession.mutate(
|
startSession.mutate(
|
||||||
@@ -177,6 +180,7 @@ export default function Stopwatch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleStop() {
|
function handleStop() {
|
||||||
|
/* c8 ignore next -- unreachable: the Stop button only renders while running (sessionId set) */
|
||||||
if (sessionId === null) return;
|
if (sessionId === null) return;
|
||||||
const id = sessionId;
|
const id = sessionId;
|
||||||
stopSession.mutate(id, { onSuccess: () => resetTimer(), onError: resetIfConflict });
|
stopSession.mutate(id, { onSuccess: () => resetTimer(), onError: resetIfConflict });
|
||||||
@@ -184,6 +188,7 @@ export default function Stopwatch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleDiscard() {
|
function handleDiscard() {
|
||||||
|
/* c8 ignore next -- unreachable: the Annuleren button only renders while running (sessionId set) */
|
||||||
if (sessionId === null) return;
|
if (sessionId === null) return;
|
||||||
if (!discardPending) {
|
if (!discardPending) {
|
||||||
setDiscardPending(true);
|
setDiscardPending(true);
|
||||||
|
|||||||
@@ -558,3 +558,43 @@ Guaranteed behaviors:
|
|||||||
- Lifecycle (`src/screens/Activities.test.tsx`): create a handeling → edit its insole types down to a
|
- 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;
|
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.
|
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 `<select>` to activities whose `insole_types` include that
|
||||||
|
type and clears any previously chosen handeling; picking the empty option (`Kies een handeling...`)
|
||||||
|
also clears the chosen handeling.
|
||||||
|
- Start is disabled until a handeling is chosen; starting fires `useStartSession` with
|
||||||
|
`{ activity_id, insole_type, pair_count }` from the current selection.
|
||||||
|
- Pair count: the stepper `+`/`−` buttons adjust the count with a floor of 1 (the `−` button is
|
||||||
|
disabled at 1); the free-typed field mirrors the raw text but only adopts a parsed value greater
|
||||||
|
than 0 (a non-positive or non-numeric entry keeps the last valid count).
|
||||||
|
- The stopwatch display is tappable: while idle with a handeling chosen it starts the session (idle
|
||||||
|
tap with no handeling is a no-op); while running it pauses via `usePauseSession`; while paused it
|
||||||
|
resumes via `useResumeSession` (folding the just-finished pause span into the local accumulator).
|
||||||
|
- On a successful start the screen adopts the returned session's running state (id, start time, cleared
|
||||||
|
pause); a paused server session is recovered into the paused UI (`Gepauzeerd — tik om te hervatten`).
|
||||||
|
- Stop (`Stop & Opslaan`) and discard both reset the timer to idle on success; a `409` (already closed
|
||||||
|
server-side) on either resets locally instead of leaving the timer stuck.
|
||||||
|
- Discard is a double-tap: the first `Annuleren` tap arms it (`Nogmaals tikken ter bevestiging`) and the
|
||||||
|
second confirms and fires `useDiscardSession`; the arm window lapses after 3s back to `Annuleren`, and
|
||||||
|
a pending arm timer is cleared both on reconcile-reset and on unmount.
|
||||||
|
- Reconcile against the active-sessions poll: while idle with a server-active session, the screen adopts
|
||||||
|
it (phone-died / resume-elsewhere); the running-vs-server check keys on session id.
|
||||||
|
- Lifecycle (`src/screens/Stopwatch.test.tsx`): running locally after a successful start, when the
|
||||||
|
active-sessions poll returns a list **without** the local session (only another worker's), the timer
|
||||||
|
resets to idle and the `Deze sessie is door de beheerder gestopt.` notice shows.
|
||||||
|
|||||||
Reference in New Issue
Block a user