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.
493 lines
18 KiB
TypeScript
493 lines
18 KiB
TypeScript
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';
|
|
import type { Activity, WorkSession } from '@solelog/shared';
|
|
import Stopwatch from './Stopwatch';
|
|
import { ApiError } from '../lib/api';
|
|
import { useActivities } from '../api/activities';
|
|
import {
|
|
useActiveSessions,
|
|
useStartSession,
|
|
useStopSession,
|
|
useDiscardSession,
|
|
usePauseSession,
|
|
useResumeSession,
|
|
} from '../api/sessions';
|
|
|
|
vi.mock('../api/activities', () => ({
|
|
useActivities: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../api/sessions', () => ({
|
|
useActiveSessions: vi.fn(),
|
|
useStartSession: vi.fn(),
|
|
useStopSession: vi.fn(),
|
|
useDiscardSession: vi.fn(),
|
|
usePauseSession: vi.fn(),
|
|
useResumeSession: vi.fn(),
|
|
}));
|
|
|
|
const mockedUseActivities = vi.mocked(useActivities);
|
|
const mockedUseActiveSessions = vi.mocked(useActiveSessions);
|
|
const mockedUseStartSession = vi.mocked(useStartSession);
|
|
const mockedUseStopSession = vi.mocked(useStopSession);
|
|
const mockedUseDiscardSession = vi.mocked(useDiscardSession);
|
|
const mockedUsePauseSession = vi.mocked(usePauseSession);
|
|
const mockedUseResumeSession = vi.mocked(useResumeSession);
|
|
|
|
const FREZEN: Activity = {
|
|
id: 1,
|
|
name: 'Frezen',
|
|
insole_types: ['Kurk', 'Berk'],
|
|
sort_order: 0,
|
|
created_at: '2026-01-01T00:00:00.000Z',
|
|
};
|
|
const PRINTEN: Activity = {
|
|
id: 2,
|
|
name: 'Printen',
|
|
insole_types: ['3D'],
|
|
sort_order: 1,
|
|
created_at: '2026-01-01T00:00:00.000Z',
|
|
};
|
|
|
|
function activeSession(overrides: Partial<WorkSession> = {}): WorkSession {
|
|
return {
|
|
id: 99,
|
|
user_id: 'u1',
|
|
activity_id: 1,
|
|
activity_name: 'Frezen',
|
|
insole_type: 'Kurk',
|
|
pair_count: 2,
|
|
start_time: new Date().toISOString(),
|
|
end_time: null,
|
|
duration_seconds: null,
|
|
paused_seconds: 0,
|
|
paused_at: null,
|
|
status: 'active',
|
|
source: 'app',
|
|
notes: null,
|
|
created_at: new Date().toISOString(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
// Build a fake RQ query/mutation result object the screen can consume.
|
|
function query<R>(data: unknown): R {
|
|
return { data, isLoading: false, isError: false } as unknown as R;
|
|
}
|
|
|
|
let startMutate: ReturnType<typeof vi.fn>;
|
|
let stopMutate: ReturnType<typeof vi.fn>;
|
|
let discardMutate: ReturnType<typeof vi.fn>;
|
|
let pauseMutate: ReturnType<typeof vi.fn>;
|
|
let resumeMutate: ReturnType<typeof vi.fn>;
|
|
|
|
function mutation<R>(mutate: ReturnType<typeof vi.fn>): R {
|
|
return { mutate, isPending: false } as unknown as R;
|
|
}
|
|
|
|
function renderStopwatch() {
|
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
return render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Stopwatch />
|
|
</QueryClientProvider>,
|
|
);
|
|
}
|
|
|
|
describe('Stopwatch', () => {
|
|
beforeEach(() => {
|
|
startMutate = vi.fn();
|
|
stopMutate = vi.fn();
|
|
discardMutate = vi.fn();
|
|
pauseMutate = vi.fn();
|
|
resumeMutate = vi.fn();
|
|
mockedUseActivities.mockReturnValue(query<ReturnType<typeof useActivities>>([FREZEN, PRINTEN]));
|
|
mockedUseActiveSessions.mockReturnValue(query<ReturnType<typeof useActiveSessions>>([]));
|
|
mockedUseStartSession.mockReturnValue(
|
|
mutation<ReturnType<typeof useStartSession>>(startMutate),
|
|
);
|
|
mockedUseStopSession.mockReturnValue(mutation<ReturnType<typeof useStopSession>>(stopMutate));
|
|
mockedUseDiscardSession.mockReturnValue(
|
|
mutation<ReturnType<typeof useDiscardSession>>(discardMutate),
|
|
);
|
|
mockedUsePauseSession.mockReturnValue(
|
|
mutation<ReturnType<typeof usePauseSession>>(pauseMutate),
|
|
);
|
|
mockedUseResumeSession.mockReturnValue(
|
|
mutation<ReturnType<typeof useResumeSession>>(resumeMutate),
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('renders the three sections and Start button in Dutch', () => {
|
|
renderStopwatch();
|
|
expect(screen.getByText('Type zool')).toBeInTheDocument();
|
|
expect(screen.getByText('Type handeling')).toBeInTheDocument();
|
|
expect(screen.getByText('Aantal zolen')).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: 'Start Stopwatch' })).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: 'Kurk' })).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: 'Berk' })).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: '3D' })).toBeInTheDocument();
|
|
// Default count is 2 (free-typed text mirror, so the value is the string '2').
|
|
expect(screen.getByLabelText('Aantal zolen')).toHaveValue('2');
|
|
});
|
|
|
|
it('disables Start until a handling is chosen', async () => {
|
|
const user = userEvent.setup();
|
|
renderStopwatch();
|
|
|
|
const startBtn = screen.getByRole('button', { name: 'Start Stopwatch' });
|
|
expect(startBtn).toBeDisabled();
|
|
|
|
// Kurk is the default zooltype; pick the Frezen handling.
|
|
await user.selectOptions(screen.getByLabelText('Type handeling'), 'Frezen');
|
|
await waitFor(() => expect(startBtn).toBeEnabled());
|
|
});
|
|
|
|
it('filters handlings by the chosen zooltype', async () => {
|
|
const user = userEvent.setup();
|
|
renderStopwatch();
|
|
|
|
const select = screen.getByLabelText('Type handeling');
|
|
// Kurk selected by default -> Frezen present, Printen absent.
|
|
expect(screen.getByRole('option', { name: 'Frezen' })).toBeInTheDocument();
|
|
expect(screen.queryByRole('option', { name: 'Printen' })).not.toBeInTheDocument();
|
|
|
|
// Switch to 3D -> Printen present, Frezen absent.
|
|
await user.click(screen.getByRole('button', { name: '3D' }));
|
|
expect(screen.getByRole('option', { name: 'Printen' })).toBeInTheDocument();
|
|
expect(screen.queryByRole('option', { name: 'Frezen' })).not.toBeInTheDocument();
|
|
expect(select).toBeInTheDocument();
|
|
});
|
|
|
|
it('calls start with the selected values', async () => {
|
|
const user = userEvent.setup();
|
|
renderStopwatch();
|
|
|
|
// Pick Berk; Frezen applies to Berk.
|
|
await user.click(screen.getByRole('button', { name: 'Berk' }));
|
|
await user.selectOptions(screen.getByLabelText('Type handeling'), 'Frezen');
|
|
// Bump count to 3.
|
|
await user.click(screen.getByRole('button', { name: 'Meer zolen' }));
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Start Stopwatch' }));
|
|
|
|
expect(startMutate).toHaveBeenCalledTimes(1);
|
|
expect(startMutate.mock.calls[0][0]).toMatchObject({
|
|
activity_id: 1,
|
|
insole_type: 'Berk',
|
|
pair_count: 3,
|
|
});
|
|
});
|
|
|
|
it('arms discard on first Annuleren tap and discards on the second', async () => {
|
|
const user = userEvent.setup();
|
|
// Recover an active session so the screen renders the running UI.
|
|
mockedUseActiveSessions.mockReturnValue(
|
|
query<ReturnType<typeof useActiveSessions>>([activeSession()]),
|
|
);
|
|
renderStopwatch();
|
|
|
|
const cancel = await screen.findByRole('button', { name: 'Annuleren' });
|
|
await user.click(cancel);
|
|
|
|
expect(
|
|
screen.getByRole('button', { name: 'Nogmaals tikken ter bevestiging' }),
|
|
).toBeInTheDocument();
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Nogmaals tikken ter bevestiging' }));
|
|
expect(discardMutate).toHaveBeenCalledTimes(1);
|
|
expect(discardMutate.mock.calls[0][0]).toBe(99);
|
|
});
|
|
|
|
it('pauses via the server when the display is tapped while running', async () => {
|
|
const user = userEvent.setup();
|
|
mockedUseActiveSessions.mockReturnValue(
|
|
query<ReturnType<typeof useActiveSessions>>([activeSession()]),
|
|
);
|
|
renderStopwatch();
|
|
|
|
const display = await screen.findByRole('button', { name: 'Stopwatch' });
|
|
await user.click(display);
|
|
|
|
expect(pauseMutate).toHaveBeenCalledTimes(1);
|
|
expect(pauseMutate.mock.calls[0][0]).toBe(99);
|
|
expect(resumeMutate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('resumes via the server when the display is tapped while paused', async () => {
|
|
const user = userEvent.setup();
|
|
mockedUseActiveSessions.mockReturnValue(
|
|
query<ReturnType<typeof useActiveSessions>>([
|
|
activeSession({ paused_at: new Date().toISOString(), paused_seconds: 30 }),
|
|
]),
|
|
);
|
|
renderStopwatch();
|
|
|
|
const display = await screen.findByRole('button', { name: 'Stopwatch' });
|
|
await user.click(display);
|
|
|
|
expect(resumeMutate).toHaveBeenCalledTimes(1);
|
|
expect(resumeMutate.mock.calls[0][0]).toBe(99);
|
|
expect(pauseMutate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('recovers a paused session into the paused state on load', async () => {
|
|
mockedUseActiveSessions.mockReturnValue(
|
|
query<ReturnType<typeof useActiveSessions>>([
|
|
activeSession({ paused_at: new Date().toISOString(), paused_seconds: 30 }),
|
|
]),
|
|
);
|
|
renderStopwatch();
|
|
|
|
expect(await screen.findByText('Gepauzeerd — tik om te hervatten')).toBeInTheDocument();
|
|
});
|
|
|
|
it('resets and shows a notice when the running session is stopped by the admin', async () => {
|
|
// Start with the session present so the screen adopts the running state.
|
|
mockedUseActiveSessions.mockReturnValue(
|
|
query<ReturnType<typeof useActiveSessions>>([activeSession()]),
|
|
);
|
|
const { rerender } = renderStopwatch();
|
|
|
|
// Confirm we are running (the Stop & Opslaan button is shown).
|
|
await screen.findByRole('button', { name: 'Stop & Opslaan' });
|
|
|
|
// The admin stops the session: the active list now no longer contains it.
|
|
mockedUseActiveSessions.mockReturnValue(query<ReturnType<typeof useActiveSessions>>([]));
|
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
rerender(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Stopwatch />
|
|
</QueryClientProvider>,
|
|
);
|
|
|
|
// The stopwatch resets to idle and shows the admin-stopped notice.
|
|
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();
|
|
});
|
|
|
|
it('resets locally when stop fails with a 409 (already closed)', async () => {
|
|
const user = userEvent.setup();
|
|
stopMutate.mockImplementation((_id: number, opts?: { onError?: (e: unknown) => void }) => {
|
|
opts?.onError?.(new ApiError(409, 'Conflict'));
|
|
});
|
|
mockedUseActiveSessions.mockReturnValue(
|
|
query<ReturnType<typeof useActiveSessions>>([activeSession()]),
|
|
);
|
|
renderStopwatch();
|
|
|
|
const stopBtn = await screen.findByRole('button', { name: 'Stop & Opslaan' });
|
|
await user.click(stopBtn);
|
|
|
|
expect(stopMutate).toHaveBeenCalledTimes(1);
|
|
// The timer resets to idle despite the 409 (no stuck running state).
|
|
await waitFor(() =>
|
|
expect(screen.getByRole('button', { name: 'Start Stopwatch' })).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();
|
|
});
|
|
});
|