diff --git a/src/app/stats/Client.tsx b/src/app/stats/Client.tsx index 0ceb24b..0f7c569 100644 --- a/src/app/stats/Client.tsx +++ b/src/app/stats/Client.tsx @@ -177,50 +177,77 @@ export default function StatsClient() { >

Stats

{status === 'error' && ( -

- {error} -

- )} - {status === 'loading' && ( -
- - Loading… -
- )} - {status === 'success' && data && ( -
-

- Router metrics -

-
- - -
- {lastUpdatedAt !== null && } -
- - +
+
+

+ Unable to load stats +

+

+ {error} +

+
)} - {status === 'success' && data && data.totalPairs === 0 && ( - - )} +
+ {status === 'loading' && ( +
+ + Loading… +
+ )} + {status === 'success' && data && data.totalPairs === 0 && ( + + )} + {status === 'success' && data && data.totalPairs > 0 && ( +
+

+ Router metrics +

+
+ + +
+ {lastUpdatedAt !== null && ( + + )} +
+ + +
+
+ )} +
); } diff --git a/src/app/stats/page.test.tsx b/src/app/stats/page.test.tsx index 17c8de8..316fd36 100644 --- a/src/app/stats/page.test.tsx +++ b/src/app/stats/page.test.tsx @@ -5,6 +5,7 @@ import { screen, waitFor, } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import StatsPage from './page'; import { buildStatsSnapshot, @@ -31,7 +32,7 @@ describe('StatsPage', () => { mockFetch({ totalPairs: 0, paused: false }); render(); expect(screen.getByRole('heading', { name: /stats/i })).toBeInTheDocument(); - await screen.findByText('Live'); + await screen.findByText(/no stats available yet/i); }); it('renders one canonical stats page region and heading', async () => { @@ -40,7 +41,7 @@ describe('StatsPage', () => { expect(screen.getAllByRole('heading', { name: /stats/i })).toHaveLength(1); expect(document.querySelectorAll('#main-content')).toHaveLength(1); - await screen.findByText('Live'); + await screen.findByText(/no stats available yet/i); }); it('names the metrics panel with an accessible region', async () => { @@ -62,26 +63,100 @@ describe('StatsPage', () => { }); it('renders Live when paused is false', async () => { - mockFetch({ totalPairs: 0, paused: false }); + mockFetch({ totalPairs: 1, paused: false }); render(); const status = await screen.findByText('Live'); expect(status).toBeInTheDocument(); }); it('renders Paused when paused is true', async () => { - mockFetch({ totalPairs: 0, paused: true }); + mockFetch({ totalPairs: 1, paused: true }); render(); const status = await screen.findByText('Paused'); expect(status).toBeInTheDocument(); }); - it('renders error message on fetch failure', async () => { + it('renders a distinct error state on fetch failure', async () => { global.fetch = jest.fn().mockRejectedValue(new Error('Network error')); render(); - await waitFor(() => { - const alert = screen.getByRole('alert'); - expect(alert).toHaveTextContent(/network request failed/i); - }); + + const alert = await screen.findByRole('alert'); + expect(alert).toHaveTextContent(/unable to load stats/i); + expect(alert).toHaveTextContent(/network request failed/i); + expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument(); + expect(screen.queryByText('Loading…')).not.toBeInTheDocument(); + expect( + screen.queryByText(/no stats available yet/i) + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('region', { name: /router metrics/i }) + ).not.toBeInTheDocument(); + }); + + it('renders an empty state instead of metrics when no stats are available', async () => { + mockFetch({ totalPairs: 0, paused: false }); + render(); + + expect( + await screen.findByText(/no stats available yet/i) + ).toBeInTheDocument(); + expect(screen.queryByText('Loading…')).not.toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect( + screen.queryByRole('region', { name: /router metrics/i }) + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /download json/i }) + ).not.toBeInTheDocument(); + }); + + it('keeps the loading state exclusive while the request is pending', () => { + global.fetch = jest.fn().mockReturnValue(new Promise(() => {})); + render(); + + expect(screen.getByText('Loading…')).toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect( + screen.queryByText(/no stats available yet/i) + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('region', { name: /router metrics/i }) + ).not.toBeInTheDocument(); + }); + + it('announces fetch state changes in one polite live region', async () => { + mockFetch({ totalPairs: 0, paused: false }); + render(); + + const liveRegion = document.querySelector('[aria-live="polite"]'); + expect(liveRegion).toHaveAttribute('aria-atomic', 'true'); + expect(liveRegion).toHaveAttribute('aria-busy', 'true'); + expect(document.querySelectorAll('[aria-live="polite"]')).toHaveLength(1); + + await screen.findByText(/no stats available yet/i); + expect(liveRegion).toHaveAttribute('aria-busy', 'false'); + }); + + it('retries the request from the keyboard and renders recovered stats', async () => { + const user = userEvent.setup(); + global.fetch = jest + .fn() + .mockRejectedValueOnce(new Error('Network error')) + .mockResolvedValueOnce({ + ok: true, + text: () => + Promise.resolve(JSON.stringify({ totalPairs: 4, paused: false })), + } as unknown as Response); + render(); + + const retry = await screen.findByRole('button', { name: /retry/i }); + retry.focus(); + await user.keyboard('{Enter}'); + + expect(await screen.findByText('4')).toBeInTheDocument(); + expect(screen.getByText('Live')).toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(global.fetch).toHaveBeenCalledTimes(2); }); it('keeps the existing 5 second polling update behavior', async () => {