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 { 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user