import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { mount, flushPromises } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import uiPlugin from '@nuxt/ui/vue-plugin' import router from '@/router' import { setLocale } from '@/i18n' import { useSessionStore } from '@/stores/session' import type { Article, ArticlePage } from '@/composables/useArticles' import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from '@/components/__tests__/viewport' import ArticleView from '../ArticleView.vue' import { oneSite } from './editorMetaKit' import { articleEnvelope, parseListUrl, type EnvelopeParams } from './articleEnvelope' const TOTAL = 312 const PLASTER_EVERY = 20 function createOverlayMock() { let resolveOpen: ((value: unknown) => void) | null = null let props: Record | null = null const create = vi.fn((_component: unknown, options: { props: Record }) => { props = options.props return { open: () => new Promise((resolve) => { resolveOpen = resolve }), } }) return { create, getProps: () => props, resolveOpen: (value: unknown) => resolveOpen?.(value), } } let overlayMock: ReturnType vi.mock('@nuxt/ui/composables', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useOverlay: () => overlayMock, useToast: () => ({ add: vi.fn() }), } }) function buildArticles(count: number): Article[] { return Array.from({ length: count }, (_, index) => { const id = index + 1 return { id, title: id % PLASTER_EVERY === 0 ? `Lime plaster notes ${id}` : `Article ${id}`, body: '', is_online: id % 3 !== 0, created_at: '2026-03-12T09:14:00Z', updated_at: '2026-08-18T09:00:00Z', } }) } function mockResponse(status: number, body: unknown = null) { return { ok: status >= 200 && status < 300, status, headers: { get: () => null }, json: async () => body, } as unknown as Response } type ListHandler = (params: EnvelopeParams, url: string) => Response | Promise let articles: Article[] let listCalls: EnvelopeParams[] let listHandler: ListHandler let deleteHandler: (id: number) => Response | Promise let wrapper: Awaited> | null = null function respondFromFixture(params: EnvelopeParams) { return mockResponse(200, articleEnvelope(articles, params)) } function installFetch() { global.fetch = vi.fn((url: string, init?: RequestInit) => { const method = init?.method ?? 'GET' if (method === 'GET' && url === '/v1/site/') { return Promise.resolve(mockResponse(200, oneSite)) } if (method === 'GET' && url.startsWith('/v1/article/?')) { const params = parseListUrl(url) listCalls.push(params) return Promise.resolve(listHandler(params, url)) } const deleted = url.match(/^\/v1\/article\/(\d+)$/) if (method === 'DELETE' && deleted) { return Promise.resolve(deleteHandler(Number(deleted[1]))) } throw new Error(`Unexpected fetch call: ${method} ${url}`) }) as unknown as typeof fetch } async function mountAt(path: string) { setActivePinia(createPinia()) overlayMock = createOverlayMock() const session = useSessionStore() session.session = { verified: true, username: 'marta' } session.status = 'ready' await router.push(path) wrapper = mount(ArticleView, { attachTo: document.body, global: { plugins: [router, uiPlugin] }, }) await flushPromises() return wrapper } function summary() { return document.body.querySelector('[data-testid="paginator-summary"]')?.textContent?.trim() } function control(label: string) { return document.body.querySelector(`button[aria-label="${label}"]`) } function pageButtons() { return Array.from( document.body.querySelectorAll('nav[aria-label="Pagination"] button'), ).filter((button) => /^\d+$/.test(button.textContent?.trim() ?? '')) } function rowIds() { return new Set( Array.from(document.body.querySelectorAll('[data-row-title]')).map((row) => row.getAttribute('data-row-title'), ), ) } function paginatorControls() { return Array.from( document.body.querySelectorAll('nav[aria-label="Pagination"] button'), ) } async function click(button: HTMLElement | null | undefined) { button?.click() await flushPromises() } function alertText() { return document.body.querySelector('[role="alert"], [role="status"]')?.textContent ?? '' } function pageSizeTrigger() { return document.body.querySelector('[aria-label="Rows per page"]') } beforeAll(() => { Element.prototype.scrollIntoView = vi.fn() }) beforeEach(() => { localStorage.clear() articles = buildArticles(TOTAL) listCalls = [] listHandler = (params) => respondFromFixture(params) deleteHandler = () => mockResponse(204) setViewportWidth(DESKTOP_WIDTH) installFetch() }) afterEach(async () => { wrapper?.unmount() wrapper = null vi.useRealTimers() vi.restoreAllMocks() await router.push('/login') }) describe('French caption', () => { it('renders the range summary in French and restores English', async () => { setLocale('fr') try { await mountAt('/article') expect(summary()).toBe('Affichage de 1 à 25 sur 312') } finally { setLocale('en') } }) }) describe('1 First page', () => { it('shows the first 25 of 312 with First and Previous disabled and no page or size in the URL', async () => { await mountAt('/article') expect(listCalls).toEqual([{ page: 1, size: 25, q: '', status: '', sort: '' }]) expect(summary()).toBe('Showing 1–25 of 312') expect(rowIds().size).toBe(25) expect(control('First page')?.disabled).toBe(true) expect(control('Previous page')?.disabled).toBe(true) expect(control('Next page')?.disabled).toBe(false) expect(control('Last page')?.disabled).toBe(false) expect(pageButtons()[0]?.getAttribute('aria-current')).toBe('page') expect(router.currentRoute.value.fullPath).toBe('/article') }) }) describe('Disabled controls', () => { it('carry the 40 % opacity class on every paginator button', async () => { await mountAt('/article') const disabled = paginatorControls().filter((button) => button.disabled) expect(disabled.length).toBeGreaterThan(1) for (const button of disabled) { expect(button.className).toContain('disabled:opacity-40') } }) it('carry it on the mobile Previous and Next buttons', async () => { setViewportWidth(MOBILE_WIDTH) await mountAt('/article') expect(control('Previous page')?.disabled).toBe(true) expect(control('Previous page')?.className).toContain('disabled:opacity-40') expect(control('Next page')?.className).toContain('disabled:opacity-40') }) }) describe('2 Middle page', () => { it('shows page 7 as current with ellipses and every control enabled', async () => { await mountAt('/article?page=7') expect(summary()).toBe('Showing 151–175 of 312') const current = pageButtons().filter((b) => b.getAttribute('aria-current') === 'page') expect(current.map((b) => b.textContent?.trim())).toEqual(['7']) expect(current[0]?.className).toContain('bg-(--rc-sand)') expect(current[0]?.className).toContain('text-(--rc-on-sand)') expect(current[0]?.className).toContain('border-primary') expect(document.body.querySelector('nav[aria-label="Pagination"]')?.textContent).toContain( '…', ) expect(paginatorControls().every((button) => !button.disabled)).toBe(true) }) }) describe('3 Last page', () => { it('shows 301–312 of 312 with Next and Last disabled', async () => { await mountAt('/article?page=13') expect(summary()).toBe('Showing 301–312 of 312') expect(rowIds().size).toBe(12) expect(control('Next page')?.disabled).toBe(true) expect(control('Last page')?.disabled).toBe(true) expect(control('Previous page')?.disabled).toBe(false) }) }) describe('4 Single page', () => { it('keeps the summary and the page-size select, hides the paginator and reads the tab counts', async () => { articles = buildArticles(6).map((article, index) => ({ ...article, is_online: index < 4, })) const wrapper = await mountAt('/article') expect(summary()).toBe('Showing 1–6 of 6') expect(document.body.querySelector('nav[aria-label="Pagination"]')).toBeNull() expect(pageSizeTrigger()).not.toBeNull() expect(wrapper.text()).toContain('All 6') expect(wrapper.text()).toContain('Online 4') expect(wrapper.text()).toContain('Drafts 2') }) }) describe('5 Loading next page', () => { it('shows skeleton rows, disables every control, and moves the URL only once the response arrives', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) await mountAt('/article') let resolvePage2: (() => void) | null = null listHandler = (params) => new Promise((resolve) => { resolvePage2 = () => resolve(respondFromFixture(params)) }) await click(control('Next page')) await vi.advanceTimersByTimeAsync(150) expect(document.body.querySelector('[aria-busy="true"]')).not.toBeNull() expect(summary()).toBe('Loading page 2…') expect(paginatorControls().every((button) => button.disabled)).toBe(true) expect(router.currentRoute.value.fullPath).toBe('/article') resolvePage2?.() await vi.advanceTimersByTimeAsync(0) await flushPromises() expect(summary()).toBe('Showing 26–50 of 312') expect(document.body.querySelector('[aria-busy="true"]')).toBeNull() expect(router.currentRoute.value.fullPath).toBe('/article?page=2') }) }) describe.each([ ['resolves with a 500 response', () => mockResponse(500)], ['never reaches the server', () => Promise.reject(new TypeError('Failed to fetch'))], ])('6 Error loading a page keeps the previous rows (%s)', (_failure, failure) => { it('keeps rows and URL, shows the notice, and Retry loads the page', async () => { await mountAt('/article?page=6') const previousRows = rowIds() listHandler = () => failure() as Promise await click(control('Next page')) expect(rowIds()).toEqual(previousRows) expect(router.currentRoute.value.fullPath).toBe('/article?page=6') expect(alertText()).toContain("Couldn't load page 7. Showing page 6.") expect(control('Next page')?.disabled).toBe(false) listHandler = (params) => respondFromFixture(params) const retry = Array.from(document.body.querySelectorAll('button')).find( (button) => button.textContent?.trim() === 'Retry', ) await click(retry) expect(listCalls.at(-1)).toMatchObject({ page: 7 }) expect(summary()).toBe('Showing 151–175 of 312') expect(router.currentRoute.value.fullPath).toBe('/article?page=7') expect(alertText()).not.toContain("Couldn't load page") }) }) describe('7 Page out of range redirects to the last page with a notice', () => { it('replaces the URL with the last page and tells the user', async () => { const push = vi.spyOn(router, 'push') const replace = vi.spyOn(router, 'replace') await mountAt('/article?page=99') expect(listCalls.map((call) => call.page)).toEqual([99, 13]) expect(replace).toHaveBeenCalledWith({ query: { page: '13' } }) expect(push).toHaveBeenCalledTimes(1) expect(router.currentRoute.value.fullPath).toBe('/article?page=13') expect(alertText()).toContain("That page doesn't exist. Showing the last page.") expect(summary()).toBe('Showing 301–312 of 312') }) }) describe('7a A shrinking server cannot loop the last-page redirect', () => { it('redirects once and then settles', async () => { let calls = 0 listHandler = (params) => { calls += 1 return mockResponse(200, { ...articleEnvelope(articles, params), items: [], total: 25 * (30 - calls), }) } await mountAt('/article?page=99') expect(listCalls.map((call) => call.page)).toEqual([99, 29]) }) }) describe('7b Out of range with total 0 shows the empty state, no redirect', () => { it('shows No articles yet and an /article URL', async () => { articles = [] const wrapper = await mountAt('/article?page=5') expect(listCalls).toHaveLength(1) expect(wrapper.text()).toContain('No articles yet') expect(router.currentRoute.value.fullPath).toBe('/article') }) }) describe('8 Search resets to page 1', () => { it('sends q with page 1, updates the URL on success and leaves the tab counts alone', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) const wrapper = await mountAt('/article?page=7') listCalls = [] await wrapper.find('input[type="search"]').setValue('plaster') await vi.advanceTimersByTimeAsync(119) expect(listCalls).toHaveLength(0) await vi.advanceTimersByTimeAsync(1) await flushPromises() expect(listCalls).toEqual([{ page: 1, size: 25, q: 'plaster', status: '', sort: '' }]) expect(router.currentRoute.value.fullPath).toBe('/article?q=plaster') expect(summary()).toBe('Showing 1–15 of 15') expect(wrapper.text()).toContain('All 312') expect(wrapper.text()).toContain('Online 208') expect(wrapper.text()).toContain('Drafts 104') }) }) describe('9 Empty after search', () => { it('hides the paginator and the summary and keeps the tab counts', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) const wrapper = await mountAt('/article') await wrapper.find('input[type="search"]').setValue('tiles') await vi.advanceTimersByTimeAsync(120) await flushPromises() expect(wrapper.text()).toContain('No articles match "tiles"') expect(wrapper.text()).toContain( 'Check the spelling, or search for a shorter part of the title.', ) expect(wrapper.text()).toContain('Clear search') expect(summary()).toBeUndefined() expect(document.body.querySelector('nav[aria-label="Pagination"]')).toBeNull() expect(wrapper.text()).toContain('All 312') expect(wrapper.text()).toContain('Online 208') expect(wrapper.text()).toContain('Drafts 104') }) }) describe('10 Page size menu', () => { it.each([ ['5 per page', 5, '/article?size=5'], ['25 per page', 25, '/article'], ['50 per page', 50, '/article?size=50'], ['100 per page', 100, '/article?size=100'], ])('choosing "%s" resets to page 1 and ends on %s', async (label, size, url) => { await mountAt('/article?page=3') listCalls = [] pageSizeTrigger()?.click() await flushPromises() const option = Array.from(document.body.querySelectorAll('[role="option"]')).find( (element) => element.textContent?.trim() === label, ) expect(option).toBeTruthy() ;(option as HTMLElement).click() await flushPromises() expect(listCalls.at(-1)).toMatchObject({ page: 1, size }) expect(router.currentRoute.value.fullPath).toBe(url) }) }) describe('10b Invalid URL values fall back', () => { it('requests page 1 and size 25 and replaces the URL', async () => { await mountAt('/article?size=10&page=abc') expect(listCalls).toEqual([{ page: 1, size: 25, q: '', status: '', sort: '' }]) expect(router.currentRoute.value.fullPath).toBe('/article') }) }) describe('11 Focus-visible', () => { it('gives every paginator control and the select the 2 px primary outline with a 2 px offset', async () => { await mountAt('/article?page=7') const controls = [...paginatorControls(), pageSizeTrigger()] expect(controls.length).toBeGreaterThan(5) 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') } }) }) describe('Keyboard, the page-size list', () => { it('opens on ArrowDown, selects on Enter and closes on Escape', async () => { await mountAt('/article') const trigger = pageSizeTrigger()! trigger.focus() trigger.dispatchEvent( new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true, }), ) await flushPromises() expect(document.body.querySelector('[role="listbox"]')).not.toBeNull() const focused = document.activeElement ?? document.body focused.dispatchEvent( new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true, }), ) await flushPromises() expect(document.body.querySelector('[role="listbox"]')).toBeNull() }) }) describe('Tabs filter on the server', () => { it('sends status=draft with page 1, updates the URL on success and keeps the counts', async () => { const wrapper = await mountAt('/article?page=3') listCalls = [] const tab = Array.from(document.body.querySelectorAll('button')).find((button) => button.textContent?.trim().startsWith('Drafts'), ) tab?.focus() tab?.dispatchEvent( new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true, }), ) await flushPromises() expect(listCalls).toEqual([{ page: 1, size: 25, q: '', status: 'draft', sort: '' }]) expect(router.currentRoute.value.fullPath).toBe('/article?status=draft') expect(wrapper.text()).toContain('All 312') expect(wrapper.text()).toContain('Online 208') expect(wrapper.text()).toContain('Drafts 104') }) }) describe('Mobile, Previous, "Page N of M", Next', () => { it('shows three controls and no page numbers below md', async () => { setViewportWidth(MOBILE_WIDTH) await mountAt('/article?page=2') const nav = document.body.querySelector('nav[aria-label="Pagination"]')! expect(nav.textContent).toContain('Previous') expect(nav.textContent).toContain('Page 2 of 13') expect(nav.textContent).toContain('Next') expect(pageButtons()).toHaveLength(0) expect(nav.querySelectorAll('button')).toHaveLength(2) }) }) describe('Deleting the last row of the last page', () => { it('reloads the previous page and recomputes the counts from the new response', async () => { articles = buildArticles(26) await mountAt('/article?page=2') expect(summary()).toBe('Showing 26–26 of 26') listCalls = [] const menu = document.body.querySelector( 'button[aria-label="Actions for Article 26"]', ) menu?.click() await flushPromises() const remove = Array.from(document.body.querySelectorAll('[role="menuitem"]')).find( (item) => item.textContent?.trim() === 'Delete…', ) as HTMLElement remove.click() await flushPromises() const action = overlayMock.getProps()?.action as () => Promise articles = buildArticles(25) await action() overlayMock.resolveOpen(true) await flushPromises() expect(listCalls).toEqual([{ page: 1, size: 25, q: '', status: '', sort: '' }]) expect(router.currentRoute.value.fullPath).toBe('/article') expect(summary()).toBe('Showing 1–25 of 25') expect(document.body.textContent).toContain('All 25') }) }) describe('The envelope is the only shape read', () => { it('renders rows from items', async () => { const page: ArticlePage = articleEnvelope(buildArticles(3)) listHandler = () => mockResponse(200, page) const wrapper = await mountAt('/article') expect(wrapper.text()).toContain('Article 1') expect(wrapper.text()).toContain('Article 3') }) }) function headerButton(label: string) { return Array.from(document.body.querySelectorAll('th button')).find( (button) => button.textContent?.trim() === label, ) } function headerArrow(label: string) { const direction = headerButton(label)?.querySelector('[data-sort-direction]') const value = direction?.getAttribute('data-sort-direction') if (value === 'asc') { return 'up' } return value === 'desc' ? 'down' : 'none' } describe('8a Sort headers, server-side, reset to page 1', () => { it.each([ ['Title', 'title:asc'], ['Status', 'status:asc'], ['Created', 'created_at:asc'], ['Updated', 'updated_at:asc'], ])('the first click on %s sends sort=%s and page 1', async (label, sort) => { const wrapper = await mountAt('/article?page=7') listCalls = [] await click(headerButton(label)) expect(listCalls).toEqual([{ page: 1, size: 25, q: '', status: '', sort }]) expect(router.currentRoute.value.fullPath).toBe(`/article?sort=${sort}`) expect(headerArrow(label)).toBe('up') expect(wrapper.text()).toContain('All 312') }) it('keeps the URL unchanged until the response arrives', async () => { await mountAt('/article?page=7') listHandler = () => new Promise(() => undefined) await click(headerButton('Title')) expect(router.currentRoute.value.fullPath).toBe('/article?page=7') }) }) describe('8b Toggling the sorted header', () => { it('toggles to descending, and returning to updated_at:desc drops sort from the URL', async () => { await mountAt('/article?sort=title:asc') listCalls = [] await click(headerButton('Title')) expect(listCalls.at(-1)).toMatchObject({ sort: 'title:desc', page: 1 }) expect(router.currentRoute.value.fullPath).toBe('/article?sort=title:desc') expect(headerArrow('Title')).toBe('down') await click(headerButton('Updated')) await click(headerButton('Updated')) expect(listCalls.at(-1)).toMatchObject({ sort: 'updated_at:desc' }) expect(router.currentRoute.value.fullPath).toBe('/article') expect(headerArrow('Updated')).toBe('down') }) }) describe('8c Sort is kept across paging, page size and search', () => { it('carries sort on Next and on a search, and the URL keeps it', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) const wrapper = await mountAt('/article?sort=title:asc') listCalls = [] await click(control('Next page')) expect(listCalls.at(-1)).toMatchObject({ page: 2, sort: 'title:asc' }) expect(router.currentRoute.value.query).toMatchObject({ page: '2', sort: 'title:asc', }) await wrapper.find('input[type="search"]').setValue('plaster') await vi.advanceTimersByTimeAsync(120) await flushPromises() expect(listCalls.at(-1)).toMatchObject({ page: 1, q: 'plaster', sort: 'title:asc', }) expect(router.currentRoute.value.query).toMatchObject({ q: 'plaster', sort: 'title:asc', }) expect(router.currentRoute.value.query.page).toBeUndefined() }) }) describe('8d A sorted URL on load', () => { it('sends the sort and shows the Created arrow-down', async () => { await mountAt('/article?sort=created_at:desc&page=2') expect(listCalls).toEqual([ { page: 2, size: 25, q: '', status: '', sort: 'created_at:desc' }, ]) expect(headerArrow('Created')).toBe('down') }) it('falls back to the default for an invalid sort and replaces the URL', async () => { await mountAt('/article?sort=body:asc') expect(listCalls).toEqual([{ page: 1, size: 25, q: '', status: '', sort: '' }]) expect(headerArrow('Updated')).toBe('down') expect(router.currentRoute.value.fullPath).toBe('/article') }) }) describe('8e No client-side re-sorting', () => { it('renders the rows in exactly the order the server returned', async () => { listHandler = (params) => { const page = articleEnvelope(articles, params) return mockResponse(200, { ...page, items: [...page.items].reverse(), }) } await mountAt('/article?sort=title:asc') const rendered = Array.from(document.body.querySelectorAll('table [data-row-title]')).map( (row) => Number(row.getAttribute('data-row-title')), ) const expected = articleEnvelope(articles, { sort: 'title:asc' }) .items.map((article) => article.id) .reverse() expect(rendered).toEqual(expected) }) }) describe('8f Caption follows the sort', () => { it('reads the default caption, then the sorted one', async () => { await mountAt('/article') expect(document.body.querySelector('caption')?.textContent?.trim()).toBe( 'Articles, sorted by last updated, newest first', ) await click(headerButton('Title')) expect(document.body.querySelector('caption')?.textContent?.trim()).toBe( 'Articles, sorted by title, ascending', ) expect(document.body.querySelector('th[aria-sort="ascending"]')?.textContent).toContain( 'Title', ) }) }) describe('8g Mobile card list', () => { it('has no sort control', async () => { await mountAt('/article') expect(document.body.querySelectorAll('ul button[aria-sort], ul [aria-sort]')).toHaveLength( 0, ) expect( Array.from(document.body.querySelectorAll('ul button')).filter((button) => /^(Title|Status|Created|Updated)$/.test(button.textContent?.trim() ?? ''), ), ).toHaveLength(0) }) })