diff --git a/apps/admin/src/screens/Live.test.tsx b/apps/admin/src/screens/Live.test.tsx index bf1fefe..8f0c630 100644 --- a/apps/admin/src/screens/Live.test.tsx +++ b/apps/admin/src/screens/Live.test.tsx @@ -194,4 +194,101 @@ describe('Live', () => { 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(); + }); }); diff --git a/docs/reference/domain-test-checklist.md b/docs/reference/domain-test-checklist.md index d411395..3c7ae69 100644 --- a/docs/reference/domain-test-checklist.md +++ b/docs/reference/domain-test-checklist.md @@ -380,3 +380,30 @@ Guaranteed behaviors: - Lifecycle (`src/screens/Login.test.tsx`): from the unauthenticated app, worker credentials are rejected with the `"Geen toegang — alleen beheerders."` message and the shell never renders; admin credentials then flip the gate so the authed shell (Sidebar nav) renders. + +## Live view — admin client + +Src covered (admin `apps/admin`): `src/screens/Live.tsx` and `src/lib/elapsed.ts` — both at +100% stmts/branch/funcs/lines. No `c8 ignore`s needed. + +Guaranteed behaviors: + +- Live renders one `.live-card` per active session under a `Actief nu ()` header, each card + showing the worker name, the activity, the pair count, and a running worked-time timer. +- The per-card worked time is server-authoritative: `worked = (base − start_time) − paused_seconds`, + where `base` is `now` while running (a 1s client tick counts it up between the 5s server polls) and + the frozen `paused_at` moment while paused, floored at 0. `formatTime` (in `elapsed.ts`) renders it + as zero-padded `HH:MM:SS` and never returns a negative time. +- A running (not-paused) card's timer keeps counting up on the 1s tick; a paused card shows the + `Gepauzeerd` badge and its worked timer stays frozen at the pause moment across ticks. +- The `Pauze ` total line shows only when `paused_seconds > 0` (absent when it is 0). +- Placeholder fallbacks: a missing `user_name` renders `Onbekend`, a missing `activity_name` renders + `Onbekende handeling`, and a null `insole_type` omits the type pill. +- Each card exposes `Stop` and `Annuleer` buttons that call `POST /api/admin/sessions/:id/stop` and + `.../discard` for that session; both buttons are disabled while either mutation is in flight. +- Loading state renders `Laden…`; the error state renders `Kon gegevens niet laden.` (and no header); + the empty state renders `Niemand is nu aan het werk.`; a non-array payload is treated as zero active + sessions (defensive `Array.isArray` fallback). +- Lifecycle (`src/screens/Live.test.tsx`): active-sessions data renders a running card and a paused + card side by side, with exactly the paused session showing the `Gepauzeerd` badge and its + `Pauze 00:01:30` total.