feat(worker): server-authoritative Stopwatch screen with active-session recovery
This commit is contained in:
185
apps/worker/src/screens/Stopwatch.test.tsx
Normal file
185
apps/worker/src/screens/Stopwatch.test.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
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,
|
||||
} 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(),
|
||||
}));
|
||||
|
||||
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 FREZEN: Activity = {
|
||||
id: 1,
|
||||
name: 'Frezen',
|
||||
insole_types: ['Kurk', 'Berk'],
|
||||
created_at: '2026-01-01T00:00:00.000Z',
|
||||
};
|
||||
const PRINTEN: Activity = {
|
||||
id: 2,
|
||||
name: 'Printen',
|
||||
insole_types: ['3D'],
|
||||
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,
|
||||
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>;
|
||||
|
||||
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();
|
||||
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),
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user