737 lines
24 KiB
TypeScript
737 lines
24 KiB
TypeScript
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<string, unknown> | null = null
|
||
const create = vi.fn((_component: unknown, options: { props: Record<string, unknown> }) => {
|
||
props = options.props
|
||
return {
|
||
open: () =>
|
||
new Promise((resolve) => {
|
||
resolveOpen = resolve
|
||
}),
|
||
}
|
||
})
|
||
return {
|
||
create,
|
||
getProps: () => props,
|
||
resolveOpen: (value: unknown) => resolveOpen?.(value),
|
||
}
|
||
}
|
||
|
||
let overlayMock: ReturnType<typeof createOverlayMock>
|
||
|
||
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||
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<Response>
|
||
|
||
let articles: Article[]
|
||
let listCalls: EnvelopeParams[]
|
||
let listHandler: ListHandler
|
||
let deleteHandler: (id: number) => Response | Promise<Response>
|
||
let wrapper: Awaited<ReturnType<typeof mount>> | 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<HTMLButtonElement>(`button[aria-label="${label}"]`)
|
||
}
|
||
|
||
function pageButtons() {
|
||
return Array.from(
|
||
document.body.querySelectorAll<HTMLButtonElement>('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<HTMLButtonElement>('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<HTMLElement>('[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<Response>
|
||
|
||
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<HTMLButtonElement>(
|
||
'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<void>
|
||
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<HTMLButtonElement>('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)
|
||
})
|
||
})
|