Drove Live.tsx from 93.24% to 100% (stmts/branch/funcs/lines); elapsed.ts already 100%. Added tests for the error state, non-array payload fallback, placeholder labels (missing name/activity/type), the busy/disabled action buttons, the loading state, and a combined running+paused lifecycle test. No production behavior changed; no bugs found.
295 lines
9.3 KiB
TypeScript
295 lines
9.3 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 { WorkSession } from '@solelog/shared';
|
|
import Live from './Live';
|
|
import { apiFetch } from '../lib/api';
|
|
|
|
vi.mock('../lib/api', () => ({
|
|
apiFetch: vi.fn(),
|
|
}));
|
|
|
|
const mockApiFetch = vi.mocked(apiFetch);
|
|
|
|
function makeSession(over: Partial<WorkSession>): WorkSession {
|
|
return {
|
|
id: 1,
|
|
user_id: 'u1',
|
|
activity_id: 10,
|
|
activity_name: 'Frezen',
|
|
user_name: 'Jan',
|
|
insole_type: 'Kurk',
|
|
pair_count: 4,
|
|
start_time: new Date(Date.now() - 65_000).toISOString(),
|
|
end_time: null,
|
|
duration_seconds: null,
|
|
paused_seconds: 0,
|
|
paused_at: null,
|
|
status: 'active',
|
|
source: 'app',
|
|
notes: null,
|
|
created_at: new Date().toISOString(),
|
|
...over,
|
|
};
|
|
}
|
|
|
|
function renderLive() {
|
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
return render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Live />
|
|
</QueryClientProvider>,
|
|
);
|
|
}
|
|
|
|
describe('Live', () => {
|
|
beforeEach(() => {
|
|
mockApiFetch.mockReset();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('renders a card per active session with header count', async () => {
|
|
mockApiFetch.mockResolvedValue([
|
|
makeSession({ id: 1, user_name: 'Jan', activity_name: 'Frezen', insole_type: 'Kurk' }),
|
|
makeSession({ id: 2, user_name: 'Piet', activity_name: 'Lijmen', insole_type: 'Berk' }),
|
|
]);
|
|
|
|
renderLive();
|
|
|
|
expect(await screen.findByText('Actief nu (2)')).toBeInTheDocument();
|
|
expect(screen.getByText('Jan')).toBeInTheDocument();
|
|
expect(screen.getByText('Frezen')).toBeInTheDocument();
|
|
expect(screen.getByText('Piet')).toBeInTheDocument();
|
|
expect(screen.getByText('Lijmen')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows the empty state when nobody is working', async () => {
|
|
mockApiFetch.mockResolvedValue([]);
|
|
|
|
renderLive();
|
|
|
|
expect(await screen.findByText('Niemand is nu aan het werk.')).toBeInTheDocument();
|
|
expect(screen.getByText('Actief nu (0)')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows a Gepauzeerd badge with a frozen worked timer when paused_at is set', async () => {
|
|
vi.useFakeTimers();
|
|
const start = Date.now() - 100_000;
|
|
const pausedAt = Date.now() - 40_000; // paused 40s ago
|
|
mockApiFetch.mockResolvedValue([
|
|
makeSession({
|
|
id: 1,
|
|
user_name: 'Jan',
|
|
start_time: new Date(start).toISOString(),
|
|
paused_at: new Date(pausedAt).toISOString(),
|
|
paused_seconds: 10,
|
|
}),
|
|
]);
|
|
|
|
renderLive();
|
|
|
|
// The fake clock needs to advance for react-query's async resolution.
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
expect(screen.getByText('Gepauzeerd')).toBeInTheDocument();
|
|
|
|
// worked = (paused_at - start)/1000 - paused_seconds = 60 - 10 = 50s = 00:00:50
|
|
const frozen = screen.getByText('00:00:50');
|
|
expect(frozen).toBeInTheDocument();
|
|
|
|
// Advance the 1s tick: the worked timer must stay frozen (does not count up).
|
|
await vi.advanceTimersByTimeAsync(3000);
|
|
expect(screen.getByText('00:00:50')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows the paused total when paused_seconds > 0', async () => {
|
|
mockApiFetch.mockResolvedValue([
|
|
makeSession({
|
|
id: 1,
|
|
user_name: 'Jan',
|
|
paused_seconds: 125,
|
|
paused_at: new Date().toISOString(),
|
|
}),
|
|
]);
|
|
|
|
renderLive();
|
|
|
|
// 125s = 00:02:05
|
|
expect(await screen.findByText('Pauze 00:02:05')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows Stop and Annuleer buttons on an active card', async () => {
|
|
mockApiFetch.mockResolvedValue([makeSession({ id: 7, user_name: 'Jan' })]);
|
|
|
|
renderLive();
|
|
|
|
expect(await screen.findByText('Jan')).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: 'Stop' })).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: 'Annuleer' })).toBeInTheDocument();
|
|
});
|
|
|
|
it('clicking Stop calls the stop endpoint for that session', async () => {
|
|
const user = userEvent.setup();
|
|
mockApiFetch.mockImplementation((path: string) => {
|
|
if (path === '/api/admin/sessions/active') {
|
|
return Promise.resolve([makeSession({ id: 7, user_name: 'Jan' })]);
|
|
}
|
|
return Promise.resolve({});
|
|
});
|
|
|
|
renderLive();
|
|
|
|
await user.click(await screen.findByRole('button', { name: 'Stop' }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockApiFetch).toHaveBeenCalledWith(
|
|
'/api/admin/sessions/7/stop',
|
|
expect.objectContaining({ method: 'POST' }),
|
|
);
|
|
});
|
|
});
|
|
|
|
it('clicking Annuleer calls the discard endpoint for that session', async () => {
|
|
const user = userEvent.setup();
|
|
mockApiFetch.mockImplementation((path: string) => {
|
|
if (path === '/api/admin/sessions/active') {
|
|
return Promise.resolve([makeSession({ id: 7, user_name: 'Jan' })]);
|
|
}
|
|
return Promise.resolve({});
|
|
});
|
|
|
|
renderLive();
|
|
|
|
await user.click(await screen.findByRole('button', { name: 'Annuleer' }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockApiFetch).toHaveBeenCalledWith(
|
|
'/api/admin/sessions/7/discard',
|
|
expect.objectContaining({ method: 'POST' }),
|
|
);
|
|
});
|
|
});
|
|
|
|
it('keeps the timer counting (no Gepauzeerd badge) when not paused', async () => {
|
|
vi.useFakeTimers();
|
|
mockApiFetch.mockResolvedValue([
|
|
makeSession({
|
|
id: 1,
|
|
user_name: 'Jan',
|
|
start_time: new Date(Date.now() - 10_000).toISOString(),
|
|
paused_at: null,
|
|
paused_seconds: 0,
|
|
}),
|
|
]);
|
|
|
|
renderLive();
|
|
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
expect(screen.queryByText('Gepauzeerd')).not.toBeInTheDocument();
|
|
expect(screen.getByText('00:00:10')).toBeInTheDocument();
|
|
|
|
await vi.advanceTimersByTimeAsync(2000);
|
|
expect(screen.getByText('00:00:12')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows the loading state before the first response resolves', () => {
|
|
mockApiFetch.mockReturnValue(new Promise(() => {})); // never resolves → stays loading
|
|
|
|
renderLive();
|
|
|
|
expect(screen.getByText('Laden…')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows the error state when the query fails', async () => {
|
|
mockApiFetch.mockRejectedValue(new Error('boom'));
|
|
|
|
renderLive();
|
|
|
|
expect(await screen.findByText('Kon gegevens niet laden.')).toBeInTheDocument();
|
|
expect(screen.queryByText(/Actief nu/)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('treats a non-array payload as no active sessions', async () => {
|
|
mockApiFetch.mockResolvedValue({} as unknown as WorkSession[]);
|
|
|
|
renderLive();
|
|
|
|
expect(await screen.findByText('Niemand is nu aan het werk.')).toBeInTheDocument();
|
|
expect(screen.getByText('Actief nu (0)')).toBeInTheDocument();
|
|
});
|
|
|
|
it('falls back to placeholder labels and hides the pill when name/activity/type are missing', async () => {
|
|
mockApiFetch.mockResolvedValue([
|
|
makeSession({ id: 1, user_name: undefined, activity_name: undefined, insole_type: null }),
|
|
]);
|
|
|
|
renderLive();
|
|
|
|
expect(await screen.findByText('Onbekend')).toBeInTheDocument();
|
|
expect(screen.getByText('Onbekende handeling')).toBeInTheDocument();
|
|
expect(screen.queryByText('Kurk')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('disables both action buttons while a stop is in flight', async () => {
|
|
const user = userEvent.setup();
|
|
mockApiFetch.mockImplementation((path: string) => {
|
|
if (path === '/api/admin/sessions/active') {
|
|
return Promise.resolve([makeSession({ id: 7, user_name: 'Jan' })]);
|
|
}
|
|
return new Promise(() => {}); // stop mutation never resolves → stays pending
|
|
});
|
|
|
|
renderLive();
|
|
|
|
await user.click(await screen.findByRole('button', { name: 'Stop' }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('button', { name: 'Stop' })).toBeDisabled();
|
|
});
|
|
expect(screen.getByRole('button', { name: 'Annuleer' })).toBeDisabled();
|
|
});
|
|
|
|
// Lifecycle: active-sessions data renders cards, and a paused session shows the paused treatment.
|
|
it('lifecycle: renders a running card and a paused card side by side', async () => {
|
|
const start = Date.now() - 100_000;
|
|
const pausedAt = Date.now() - 40_000;
|
|
mockApiFetch.mockResolvedValue([
|
|
makeSession({
|
|
id: 1,
|
|
user_name: 'Jan',
|
|
activity_name: 'Frezen',
|
|
insole_type: 'Kurk',
|
|
pair_count: 4,
|
|
paused_at: null,
|
|
paused_seconds: 0,
|
|
}),
|
|
makeSession({
|
|
id: 2,
|
|
user_name: 'Piet',
|
|
activity_name: 'Lijmen',
|
|
insole_type: 'Berk',
|
|
pair_count: 6,
|
|
start_time: new Date(start).toISOString(),
|
|
paused_at: new Date(pausedAt).toISOString(),
|
|
paused_seconds: 90, // 00:01:30
|
|
}),
|
|
]);
|
|
|
|
renderLive();
|
|
|
|
// Both cards render under the header count.
|
|
expect(await screen.findByText('Actief nu (2)')).toBeInTheDocument();
|
|
expect(screen.getByText('Jan')).toBeInTheDocument();
|
|
expect(screen.getByText('Frezen')).toBeInTheDocument();
|
|
expect(screen.getByText('Piet')).toBeInTheDocument();
|
|
expect(screen.getByText('Lijmen')).toBeInTheDocument();
|
|
|
|
// Exactly the paused session shows the paused treatment.
|
|
expect(screen.getAllByText('Gepauzeerd')).toHaveLength(1);
|
|
expect(screen.getByText('Pauze 00:01:30')).toBeInTheDocument();
|
|
});
|
|
});
|