Files
solelog/apps/worker/src/screens/Stopwatch.test.tsx
Bas van Rossem 1807f2b6d6 docs: pause-accounting + reorder session log
Finalize the pause-accounting + reorderable-handelingen + login-tab-fix
feature: session log (goal/work/verification/outcome), a one-line roadmap
status note, and an oxfmt pass over the changed files that strips a stray
trailing comma after the last call argument in the worker Stopwatch (es5
trailing-comma style) — pure formatting, tests stay green.
2026-06-17 21:24:16 +02:00

251 lines
8.6 KiB
TypeScript

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