import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' import { flushPromises } from '@vue/test-utils' import router from '@/router' import { MOBILE_WIDTH } from '@/components/__tests__/viewport' import { SKELETON_DELAY_MS, calls, chooseSize, eventEntries, failWith, installSelectPolyfills, listPage, listRequests, mountList, pending, press, text, unmountAll, } from './contentTestKit' vi.mock('@/composables/useApi', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useApi: vi.fn() } }) beforeAll(installSelectPolyfills) afterEach(unmountAll) const TOTAL = 37 function paged(_: string, query: URLSearchParams) { const size = Number(query.get('size')) const page = Number(query.get('page')) const remaining = Math.max(0, Math.min(size, TOTAL - (page - 1) * size)) return listPage( Array.from({ length: Math.min(remaining, 7) }, (__, index) => eventEntries[index]), TOTAL, query, ) } function main() { return document.body.querySelector('#dashboard-content')! } function summary() { return text(document.body.querySelector('[data-testid="paginator-summary"]')) } function pagerButtons() { return [ ...document.body.querySelectorAll('nav[aria-label="Pagination"] button'), ] } function pagerLabels() { return [...document.body.querySelectorAll('nav[aria-label="Pagination"] > *')].map( (element) => element.getAttribute('aria-label') ?? text(element), ) } function lastRequest() { return listRequests(calls).at(-1) } function alertText() { return text(main().querySelector('[role="alert"], [role="status"]')) } describe('2 Paginated, 5 per page', () => { it('shows the summary, the page size and the paginator', async () => { await mountList('/content/events?size=5', { list: paged }) expect(summary()).toBe('Showing 1–5 of 37') expect(text(document.body.querySelector('[aria-label="Rows per page"]'))).toBe('5 per page') expect(pagerLabels()).toEqual([ 'First page', 'Previous page', '1', '2', '…', '8', 'Next page', 'Last page', ]) expect( pagerButtons() .slice(0, 2) .every((button) => button.disabled), ).toBe(true) expect( main() .querySelector('nav[aria-label="Pagination"] [aria-current="page"]') ?.textContent?.trim(), ).toBe('1') expect(main().querySelector('[data-testid="paginator-count"]')).toBeNull() }) it('shows Previous, Page 1 of 8 and Next on mobile', async () => { await mountList('/content/events?size=5', { list: paged, width: MOBILE_WIDTH }) const nav = text(main().querySelector('nav[aria-label="Pagination"]')) expect(nav).toBe('Previous Page 1 of 8 Next') expect( main().querySelector('[aria-label="Previous page"]')?.disabled, ).toBe(true) }) it('sends page 2 on Next and updates the URL on success', async () => { await mountList('/content/events?size=5', { list: paged }) await press('Next page') expect(lastRequest()).toBe( '/v1/content-type/events/entry?page=2&size=5&sort=updated_at:desc', ) expect(summary()).toBe('Showing 6–10 of 37') expect(router.currentRoute.value.fullPath).toBe('/content/events?size=5&page=2') }) it('goes to the last page with Last', async () => { await mountList('/content/events?size=5', { list: paged }) await press('Last page') expect(summary()).toBe('Showing 36–37 of 37') expect(router.currentRoute.value.fullPath).toBe('/content/events?size=5&page=8') expect( pagerButtons() .filter((button) => ['Next page', 'Last page'].includes(button.getAttribute('aria-label')!), ) .every((button) => button.disabled), ).toBe(true) }) }) describe('Page size select', () => { it.each([ ['5 per page', 5, '/content/events?size=5'], ['25 per page', 25, '/content/events'], ['50 per page', 50, '/content/events?size=50'], ['100 per page', 100, '/content/events?size=100'], ])('choosing "%s" resets to page 1 and ends on %s', async (label, size, url) => { await mountList('/content/events?size=5&page=3', { list: paged }) await chooseSize(label) expect(lastRequest()).toBe( `/v1/content-type/events/entry?page=1&size=${size}&sort=updated_at:desc`, ) expect(router.currentRoute.value.fullPath).toBe(url) }) it('does not remember the page size', async () => { await mountList('/content/events', { list: paged }) await chooseSize('50 per page') await unmountAll() await mountList('/content/events', { list: paged }) expect(lastRequest()).toBe( '/v1/content-type/events/entry?page=1&size=25&sort=updated_at:desc', ) expect(Object.keys(localStorage).filter((key) => key.includes('page-size'))).toEqual([]) }) }) describe('Loading a page', () => { it('shows Loading page 2…, disables the controls and marks the list busy', async () => { vi.useFakeTimers() const gate = pending() await mountList('/content/events?size=5', { list: (key, query) => (query.get('page') === '2' ? gate.promise : paged(key, query)), }) document.body.querySelector('[aria-label="Next page"]')!.click() vi.advanceTimersByTime(SKELETON_DELAY_MS) await flushPromises() expect(summary()).toBe('Loading page 2…') expect(pagerButtons().every((button) => button.disabled)).toBe(true) expect( document.body.querySelector('[aria-label="Rows per page"]') ?.disabled, ).toBe(true) expect(main().querySelector('[aria-busy="true"]')).not.toBeNull() expect(main().querySelectorAll('[data-testid="entry-skeleton-row"]')).toHaveLength(5) expect(router.currentRoute.value.fullPath).toBe('/content/events?size=5') }) }) describe('Error loading a page', () => { it('keeps the rows, shows the notice with Retry and leaves the URL', async () => { await mountList('/content/events?size=5', { list: (key, query) => { if (query.get('page') === '2') { throw new Error('x') } return paged(key, query) }, }) await press('Next page') expect(alertText()).toContain("Couldn't load page 2. Showing page 1.") expect(main().querySelectorAll('tbody tr')).toHaveLength(5) expect(summary()).toBe('Showing 1–5 of 37') expect(router.currentRoute.value.fullPath).toBe('/content/events?size=5') expect(main().querySelector('h1')?.textContent).toBe('Events') }) it('Retry sends the failed page again and updates the URL on success', async () => { let attempt = 0 await mountList('/content/events?size=5', { list: (key, query) => { if (query.get('page') === '2' && attempt++ === 0) { throw new Error('x') } return paged(key, query) }, }) await press('Next page') const retry = [...main().querySelectorAll('button')].find( (candidate) => text(candidate) === 'Retry', )! retry.click() await flushPromises() expect(summary()).toBe('Showing 6–10 of 37') expect(alertText()).toBe('') expect(router.currentRoute.value.fullPath).toBe('/content/events?size=5&page=2') }) it('shows the error state, not a notice, when the first load fails', async () => { await mountList('/content/events?page=2', { list: failWith(500) }) expect(text(main())).toContain("Couldn't load entries") expect(text(main())).not.toContain("Couldn't load page") }) }) describe('Out-of-range page', () => { it('loads the last page, shows the notice and replaces the URL', async () => { await mountList('/content/events?size=5&page=99', { list: (key, query) => Number(query.get('page')) > 8 ? listPage([], TOTAL, query) : paged(key, query), }) expect(lastRequest()).toBe( '/v1/content-type/events/entry?page=8&size=5&sort=updated_at:desc', ) expect(alertText()).toContain("That page doesn't exist. Showing the last page.") expect(summary()).toBe('Showing 36–37 of 37') expect(router.currentRoute.value.fullPath).toBe('/content/events?size=5&page=8') }) it('shows the empty state and cleans the URL when the total is 0', async () => { await mountList('/content/events?page=3', { list: (_, query) => listPage([], 0, query) }) expect(text(main())).toContain('No entries yet') expect(main().querySelector('[data-testid="paginator-summary"]')).toBeNull() expect(router.currentRoute.value.fullPath).toBe('/content/events') }) }) describe('Focus-visible', () => { it('gives the paginator, the select and the toolbar controls a 2 px primary outline with a 2 px offset', async () => { await mountList('/content/events?size=5&page=3', { list: paged }) const controls = [ ...pagerButtons(), document.body.querySelector('[aria-label="Rows per page"]'), document.body.querySelector('[aria-label="Sort by"]'), document.body.querySelector('[aria-label="Sort ascending"]'), [...document.body.querySelectorAll('button')].find( (candidate) => text(candidate) === 'Columns', ), ] expect(controls.length).toBeGreaterThan(8) for (const element of controls) { expect(element?.className).toContain('focus-visible:outline-2') expect(element?.className).toContain('focus-visible:outline-offset-2') expect(element?.className).toContain('focus-visible:outline-primary') } }) })