test(10-05): bring every SPA module, composable and component under Vitest

- 41 unit and component suites under admin/tests/{app,state,shell,list,form,relation,views,ui}
  covering states and a11y roles; every src module is imported by a test
- typed fixture helper assigns each JSON fixture to its generated OpenAPI type
- fix: iconFor ignores inherited object members such as "constructor"
- fix: field controls import ./control instead of the registry (import cycle
  left a renderer unregistered depending on module load order)
- fix: dropdown shows the placeholder for an unknown stored value next to an emptyOption
- fix: list announces a failed schema load even when the rows arrive after it
- tailwind no longer scans admin/tests; boardwalk/dist rebuilt
This commit is contained in:
Jakub Zych
2026-09-27 17:53:57 +02:00
parent 8259a456bb
commit 1c2a66df45
61 changed files with 4299 additions and 91 deletions

View File

@@ -0,0 +1,77 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import CellValue from '../../src/components/list/CellValue.vue'
import type { ListColumn } from '../../src/api/types'
import { listColumn } from '../fixtures/typed'
import { resetState } from '../helpers'
function cell(column: ListColumn, value: unknown) {
return mount({ components: { CellValue }, props: [], template: '<span data-cell><CellValue :column="column" :value="value" /></span>', data: () => ({ column, value }) })
}
const text = listColumn('name')
const datetime = listColumn('created_at')
const toggle = listColumn('active')
beforeEach(() => {
resetState()
})
describe('cell value (D-12)', () => {
it.each([
['a string', 'Blue', 'Blue'],
['a number', 12, '12'],
['an array', ['small', 3, null, ''], 'small, 3'],
['a boolean true', true, 'Tak'],
['a boolean false', false, 'Nie'],
])('renders %s as plain text', (_label, value, expected) => {
const wrapper = cell(text, value)
expect(wrapper.text()).toBe(expected)
expect(wrapper.find('[data-empty]').exists()).toBe(false)
})
it.each([
['null', null],
['undefined', undefined],
['an empty string', ''],
['an object', { nested: true }],
['an empty array', []],
])('renders %s as a muted dash', (_label, value) => {
const wrapper = cell(text, value)
expect(wrapper.find('[data-empty]').text()).toBe('—')
})
it('never renders markup from a value', () => {
const wrapper = cell(text, '<img src=x onerror=alert(1)>')
expect(wrapper.find('img').exists()).toBe(false)
expect(wrapper.text()).toBe('<img src=x onerror=alert(1)>')
})
it('formats datetimes as YYYY-MM-DD HH:mm in tabular numbers', () => {
const wrapper = cell(datetime, '2026-09-21T14:32:00')
expect(wrapper.text()).toBe('2026-09-21 14:32')
expect(wrapper.find('span.tabular-nums').exists()).toBe(true)
expect(cell(datetime, 'not a date').text()).toBe('not a date')
expect(cell(datetime, null).find('[data-empty]').exists()).toBe(true)
expect(cell(datetime, true).find('[data-empty]').exists()).toBe(true)
})
it.each([
[true, 'true', 'Tak'],
[1, 'true', 'Tak'],
['yes', 'true', 'Tak'],
[false, 'false', 'Nie'],
[0, 'false', 'Nie'],
['0', 'false', 'Nie'],
['FALSE', 'false', 'Nie'],
])('renders the switch value %j as the %s pill', (value, state, label) => {
const wrapper = cell(toggle, value)
expect(wrapper.find('[data-switch]').attributes('data-switch')).toBe(state)
expect(wrapper.text()).toBe(label)
})
it('renders an empty switch value as a dash', () => {
expect(cell(toggle, null).find('[data-empty]').exists()).toBe(true)
expect(cell(toggle, null).find('[data-switch]').exists()).toBe(false)
})
})

View File

@@ -0,0 +1,143 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import DataTable from '../../src/components/list/DataTable.vue'
import type { AdminRecord, ListColumn } from '../../src/api/types'
import type { RowId } from '../../src/app/listQuery'
import { linkedFixture, listColumn, listFixture } from '../fixtures/typed'
import { resetState, routerAt, signIn } from '../helpers'
const columns: ListColumn[] = [listColumn('name'), listColumn('code'), listColumn('tags')]
async function mountTable(props: Record<string, unknown>) {
await signIn()
const router = await routerAt('/acme/demo/widgets')
const wrapper = mount(DataTable, {
props: { columns, rows: listFixture.data, ...props },
global: { plugins: [router] },
attachTo: document.body,
})
await flushPromises()
return { wrapper, router }
}
const rowLink = (row: AdminRecord) => `/acme/demo/widgets/${String(row.id)}`
beforeEach(() => {
resetState()
})
afterEach(() => {
document.body.innerHTML = ''
})
enableAutoUnmount(afterEach)
describe('data table', () => {
it('renders exactly the schema columns and each record through its column keys', async () => {
const { wrapper } = await mountTable({})
expect(wrapper.findAll('th').map((th) => th.text())).toEqual(['Name', 'Code', 'Tags'])
expect(wrapper.findAll('th').every((th) => th.attributes('scope') === 'col')).toBe(true)
const cells = wrapper.findAll('tbody tr').map((tr) => tr.findAll('td').map((td) => td.text()))
expect(cells).toEqual([
['Blue widget', 'W-01', 'small, round'],
['Green widget', 'W-02', '—'],
])
expect(wrapper.find('tbody tr').attributes('aria-selected')).toBeUndefined()
expect(wrapper.find('th').attributes('aria-sort')).toBeUndefined()
})
it('marks sortable headers with aria-sort none, ascending or descending and emits the column', async () => {
const { wrapper } = await mountTable({ sortable: true, sort: { column: 'code', dir: 'desc' } })
const headers = wrapper.findAll('th')
expect(headers.map((th) => th.attributes('aria-sort'))).toEqual(['none', 'descending', undefined])
expect(headers[2]!.find('button').exists()).toBe(false)
await wrapper.setProps({ sort: { column: 'name', dir: 'asc' } })
expect(wrapper.findAll('th').map((th) => th.attributes('aria-sort'))).toEqual(['ascending', 'none', undefined])
await wrapper.find('[data-sort="code"]').trigger('click')
expect(wrapper.emitted('sort')).toEqual([['code']])
})
it('selects rows through a tri-state header checkbox over the current page', async () => {
const { wrapper } = await mountTable({ selectable: true, selected: [] as RowId[] })
const header = () => wrapper.find('th[data-select] [role="checkbox"]')
expect(header().attributes('aria-checked')).toBe('false')
expect(header().attributes('aria-label')).toBe('Zaznacz wszystkie na stronie')
expect(wrapper.findAll('tbody tr').map((tr) => tr.attributes('aria-selected'))).toEqual(['false', 'false'])
await header().trigger('click')
expect(wrapper.emitted('update:selected')!.at(-1)).toEqual([[1, 2]])
await wrapper.setProps({ selected: ['2'] })
expect(header().attributes('aria-checked')).toBe('mixed')
expect(wrapper.findAll('tbody tr').map((tr) => tr.attributes('aria-selected'))).toEqual(['false', 'true'])
await header().trigger('click')
expect(wrapper.emitted('update:selected')!.at(-1)).toEqual([[1, 2]])
await wrapper.setProps({ selected: [1, 2] })
expect(header().attributes('aria-checked')).toBe('true')
await header().trigger('click')
expect(wrapper.emitted('update:selected')!.at(-1)).toEqual([[]])
})
it('toggles single rows by id', async () => {
const { wrapper } = await mountTable({ selectable: true, selected: [2] as RowId[] })
const boxes = wrapper.findAll('tbody [role="checkbox"]')
expect(boxes.map((box) => box.attributes('aria-checked'))).toEqual(['false', 'true'])
expect(boxes[0]!.attributes('aria-label')).toBe('Zaznacz wiersz')
await boxes[0]!.trigger('click')
expect(wrapper.emitted('update:selected')!.at(-1)).toEqual([[2, 1]])
await boxes[1]!.trigger('click')
expect(wrapper.emitted('update:selected')!.at(-1)).toEqual([[]])
})
it('opens the row link on a row click but not from its controls', async () => {
const { wrapper, router } = await mountTable({ selectable: true, rowLink })
expect(wrapper.find('tbody tr td:nth-child(2) a').attributes('href')).toBe('/acme/demo/widgets/1')
await wrapper.find('tbody tr [role="checkbox"]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.path).toBe('/acme/demo/widgets')
await wrapper.findAll('tbody tr')[1]!.findAll('td')[2]!.trigger('click')
await flushPromises()
expect(router.currentRoute.value.path).toBe('/acme/demo/widgets/2')
})
it('does nothing on a row click without a row link and keeps rows without ids unselectable', async () => {
const { wrapper, router } = await mountTable({
selectable: true,
rows: [{ name: 'No id', code: 'X', tags: [] }],
})
await wrapper.find('tbody td:nth-child(2)').trigger('click')
await wrapper.find('tbody [role="checkbox"]').trigger('click')
expect(router.currentRoute.value.path).toBe('/acme/demo/widgets')
expect(wrapper.emitted('update:selected')).toBeUndefined()
expect(wrapper.find('th [role="checkbox"]').attributes('disabled')).toBeDefined()
})
it('shows eight skeleton rows with aria-busy while loading and the empty slot without rows', async () => {
const { wrapper } = await mountTable({ loading: true, selectable: true })
expect(wrapper.find('tbody').attributes('aria-busy')).toBe('true')
expect(wrapper.findAll('[data-skeleton]')).toHaveLength(8)
expect(wrapper.find('th [role="checkbox"]').attributes('disabled')).toBeDefined()
await wrapper.setProps({ loading: false, rows: [] })
expect(wrapper.find('tbody').attributes('aria-busy')).toBeUndefined()
expect(wrapper.find('tbody td').attributes('colspan')).toBe('4')
expect(wrapper.find('tbody td').text()).toBe('Brak rekordów w tym widoku.')
})
it('renders the relation variant with 56px rows, initials avatars and three skeleton rows', async () => {
const relationColumns = [
{ key: 'name', label: 'Name', searchable: true, sortable: true },
{ key: 'email', label: 'E-mail', searchable: true, sortable: true },
]
const { wrapper } = await mountTable({ variant: 'relation', columns: relationColumns, rows: linkedFixture.all.data })
const first = wrapper.find('tbody tr')
expect(first.classes()).toContain('h-[56px]')
expect(first.find('[data-avatar]').text()).toBe('AB')
expect(first.find('[data-avatar]').attributes('aria-hidden')).toBe('true')
expect(first.findAll('td')[1]!.classes()).toContain('text-muted')
await wrapper.setProps({ loading: true })
expect(wrapper.findAll('[data-skeleton]')).toHaveLength(3)
})
})

View File

@@ -0,0 +1,121 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import FilterBar from '../../src/components/list/FilterBar.vue'
import type { ListFilter } from '../../src/api/types'
import { listFilter, optionsFixture } from '../fixtures/typed'
import { API, mockApi, queryOf, requestsTo, resetState } from '../helpers'
const source = { vendor: 'acme', plugin: 'demo', controller: 'widgets' }
const MAKER = `${API}/acme/demo/widgets/filters/maker/options`
const filters: ListFilter[] = [listFilter('active'), listFilter('created'), listFilter('maker')]
async function mountBar(values: Record<string, string> = {}, extra: ListFilter[] = []) {
const calls = mockApi({ [`GET ${MAKER}`]: { body: optionsFixture.maker } })
const wrapper = mount(FilterBar, { props: { filters: [...filters, ...extra], values, source } })
await flushPromises()
return { wrapper, calls }
}
function options(select: ReturnType<ReturnType<typeof mount>['find']>) {
return select.findAll('option').map((option) => [option.attributes('value'), option.text()])
}
beforeEach(() => {
resetState()
})
afterEach(() => {
document.body.innerHTML = ''
})
enableAutoUnmount(afterEach)
describe('filter bar (D-22, D-27)', () => {
it('is a labelled group with one control per filter shape', async () => {
const { wrapper } = await mountBar()
expect(wrapper.find('[role="group"]').attributes('aria-label')).toBe('Filtry')
expect(wrapper.find('select[data-filter="active"]').exists()).toBe(true)
expect(wrapper.find('fieldset legend').text()).toBe('Created')
expect(wrapper.find('[data-filter="created-from"]').attributes('aria-label')).toBe('Od')
expect(wrapper.find('[data-filter="created-to"]').attributes('aria-label')).toBe('Do')
expect(wrapper.find('select[data-filter="maker"]').exists()).toBe(true)
})
it('offers switch choices as JSON of trueValue and falseValue, or of the declared options', async () => {
const withOptions: ListFilter = {
name: 'size',
type: 'switch',
options: [
{ value: 's', label: 'Small' },
{ value: 2, label: 'Two' },
],
}
const { wrapper } = await mountBar({}, [withOptions])
expect(options(wrapper.find('select[data-filter="active"]'))).toEqual([
['', 'Wszystkie'],
['true', 'Tak'],
['false', 'Nie'],
])
expect(options(wrapper.find('select[data-filter="size"]'))).toEqual([
['', 'Wszystkie'],
['"s"', 'Small'],
['2', 'Two'],
])
expect(wrapper.find('select[data-filter="size"]').element.closest('label')?.textContent).toContain('size')
await wrapper.find('select[data-filter="active"]').setValue('false')
await wrapper.find('select[data-filter="active"]').setValue('')
expect(wrapper.emitted('update')).toEqual([
['active', 'false'],
['active', null],
])
})
it('loads scope choices once from the filter options endpoint and selects the URL value', async () => {
const { wrapper, calls } = await mountBar({ maker: '2' })
const requests = requestsTo(calls, 'GET', MAKER)
expect(requests).toHaveLength(1)
expect([...queryOf(requests[0]!).keys()]).toEqual([])
const select = wrapper.find('select[data-filter="maker"]')
expect(options(select)).toEqual([
['', 'Wszystkie'],
['1', 'Acme Works'],
['2', 'Bolt & Co'],
])
expect((select.element as HTMLSelectElement).value).toBe('2')
await select.setValue('1')
expect(wrapper.emitted('update')).toEqual([['maker', '1']])
})
it('shows no scope choices when the options request fails', async () => {
mockApi({ [`GET ${MAKER}`]: { status: 403, body: { error: { code: 'forbidden', message: 'x', details: {} } } } })
const wrapper = mount(FilterBar, { props: { filters, values: {}, source } })
await flushPromises()
expect(options(wrapper.find('select[data-filter="maker"]'))).toEqual([['', 'Wszystkie']])
})
it('writes a date range only when both ends are set and removes it when either is cleared', async () => {
const { wrapper } = await mountBar()
const from = wrapper.find('[data-filter="created-from"]')
const to = wrapper.find('[data-filter="created-to"]')
await from.setValue('2026-01-01')
expect(wrapper.emitted('update')).toBeUndefined()
await to.setValue('2026-02-01')
expect(wrapper.emitted('update')).toEqual([['created', '2026-01-01..2026-02-01']])
await wrapper.setProps({ values: { created: '2026-01-01..2026-02-01' } })
await to.setValue('')
expect(wrapper.emitted('update')!.at(-1)).toEqual(['created', null])
await wrapper.setProps({ values: {} })
// The half-filled pair stays on screen until both ends are cleared.
expect((from.element as HTMLInputElement).value).toBe('2026-01-01')
await from.setValue('')
expect(wrapper.emitted('update')!.at(-1)).toEqual(['created', null])
})
it('fills the range inputs from the URL value', async () => {
const { wrapper } = await mountBar({ created: '2026-03-01..2026-03-31' })
expect((wrapper.find('[data-filter="created-from"]').element as HTMLInputElement).value).toBe('2026-03-01')
expect((wrapper.find('[data-filter="created-to"]').element as HTMLInputElement).value).toBe('2026-03-31')
})
})

View File

@@ -0,0 +1,57 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import ListToolbar from '../../src/components/list/ListToolbar.vue'
import { resetState } from '../helpers'
const base = {
showSearch: true,
search: 'blue',
searchPrompt: 'Search widgets…',
buttons: ['delete'],
selectedCount: 0,
selectedLabel: 'Selected 0',
deleteLabel: 'Delete selected',
}
beforeEach(() => {
resetState()
})
describe('list toolbar (D-14)', () => {
it('has a labelled search input that emits every change', async () => {
const wrapper = mount(ListToolbar, { props: base })
const input = wrapper.find('[data-list-search]')
expect(input.attributes('type')).toBe('search')
expect(input.attributes('placeholder')).toBe('Search widgets…')
expect((input.element as HTMLInputElement).value).toBe('blue')
expect(wrapper.find('label .sr-only').text()).toBe('Szukaj')
await input.setValue('red')
expect(wrapper.emitted('update:search')).toEqual([['red']])
})
it('hides the search when the schema turns it off', () => {
const wrapper = mount(ListToolbar, { props: { ...base, showSearch: false } })
expect(wrapper.find('[data-list-search]').exists()).toBe(false)
})
it('disables delete without a selection and shows the pill and danger button with one', async () => {
const wrapper = mount(ListToolbar, { props: base })
const button = () => wrapper.find('[data-action="delete"]')
expect(button().attributes('disabled')).toBeDefined()
expect(button().classes()).toContain('border-border-strong')
expect(wrapper.find('[data-selected-pill]').exists()).toBe(false)
await wrapper.setProps({ selectedCount: 2, selectedLabel: 'Selected 2' })
expect(button().attributes('disabled')).toBeUndefined()
expect(button().classes()).toContain('text-danger')
expect(button().text()).toBe('Delete selected')
expect(wrapper.find('[data-selected-pill]').text()).toBe('Selected 2')
await button().trigger('click')
expect(wrapper.emitted('delete')).toHaveLength(1)
})
it('renders only the declared toolbar buttons', () => {
const wrapper = mount(ListToolbar, { props: { ...base, buttons: [] } })
expect(wrapper.find('[data-action="delete"]').exists()).toBe(false)
})
})

View File

@@ -0,0 +1,132 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount, flushPromises } from '@vue/test-utils'
import ListView from '../../src/views/ListView.vue'
import { toasts } from '../../src/state/useToasts'
import { clone, listFixture, listSchemaFixture } from '../fixtures/typed'
import { API, mountApp, queryOf, requestsTo, resetState, type Route } from '../helpers'
const BASE = `${API}/acme/demo/widgets`
const serverError = { status: 500, body: { error: { code: 'server_error', message: 'Database down', details: {} } } }
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
return {
[`GET ${BASE}/schema/list`]: { body: listSchemaFixture },
[`GET ${BASE}`]: { body: listFixture },
...overrides,
}
}
function schemaWith(change: (schema: typeof listSchemaFixture.data) => void) {
const envelope = clone(listSchemaFixture)
change(envelope.data)
return { body: envelope }
}
async function selectAllAndDelete(wrapper: Awaited<ReturnType<typeof mountApp>>['wrapper']) {
await wrapper.find('th [role="checkbox"]').trigger('click')
await wrapper.find('[data-action="delete"]').trigger('click')
await flushPromises()
;(document.body.querySelector('[data-confirm-dialog] [data-action="confirm"]') as HTMLElement).click()
await flushPromises()
}
beforeEach(() => {
resetState()
})
afterEach(() => {
document.body.innerHTML = ''
})
enableAutoUnmount(afterEach)
describe('list view', () => {
it('is the component of the list route', async () => {
const { wrapper } = await mountApp('/acme/demo/widgets', routes())
expect(wrapper.findComponent(ListView).exists()).toBe(true)
expect(wrapper.find('h1').text()).toBe('Widgets')
expect(wrapper.find('[data-record-count]').text()).toBe('2 widgets')
})
it('sends the URL query to the list endpoint', async () => {
const { calls } = await mountApp('/acme/demo/widgets?search=blue&sort=code&dir=desc&page=2&filter[active]=true', routes())
const [request] = requestsTo(calls, 'GET', BASE)
const query = queryOf(request!)
expect(query.get('search')).toBe('blue')
expect(query.get('sort')).toBe('code')
expect(query.get('dir')).toBe('desc')
expect(query.get('page')).toBe('2')
expect(query.get('filter[active]')).toBe('true')
})
it('announces a failed list load as an alert and keeps the toolbar', async () => {
const { wrapper } = await mountApp('/acme/demo/widgets', routes({ [`GET ${BASE}`]: serverError }))
expect(wrapper.find('[role="alert"]').text()).toBe('Nie udało się wczytać listy.')
expect(wrapper.find('table').exists()).toBe(false)
expect(wrapper.find('[data-list-search]').exists()).toBe(true)
})
it('announces a failed schema load and falls back to the navigation label', async () => {
const { wrapper } = await mountApp('/acme/demo/gadgets', {
[`GET ${API}/acme/demo/gadgets/schema/list`]: serverError,
[`GET ${API}/acme/demo/gadgets`]: { body: listFixture },
})
expect(wrapper.find('h1').text()).toBe('Gadgets')
expect(wrapper.find('[role="alert"]').exists()).toBe(true)
expect(wrapper.find('[data-list-search]').exists()).toBe(false)
})
it('shows the create button only when the toolbar declares it', async () => {
const withCreate = await mountApp('/acme/demo/widgets', routes())
expect(withCreate.wrapper.find('[data-action="create"]').attributes('href')).toBe('/acme/demo/widgets/create')
withCreate.wrapper.unmount()
const { wrapper } = await mountApp(
'/acme/demo/widgets',
routes({ [`GET ${BASE}/schema/list`]: schemaWith((schema) => (schema.toolbarButtons = ['delete'])) }),
)
expect(wrapper.find('[data-action="create"]').exists()).toBe(false)
expect(wrapper.find('[data-action="delete"]').exists()).toBe(true)
})
it('links no rows without a recordUrl and shows no checkboxes or sorting when turned off', async () => {
const { wrapper } = await mountApp(
'/acme/demo/widgets',
routes({
[`GET ${BASE}/schema/list`]: schemaWith((schema) => {
delete schema.recordUrl
schema.showCheckboxes = false
schema.showSorting = false
}),
}),
)
expect(wrapper.find('tbody a').exists()).toBe(false)
expect(wrapper.find('[role="checkbox"]').exists()).toBe(false)
expect(wrapper.find('[data-sort]').exists()).toBe(false)
})
it('toasts the server message when a bulk delete is refused', async () => {
const { wrapper } = await mountApp(
'/acme/demo/widgets',
routes({ [`POST ${BASE}/bulk-delete`]: { status: 403, body: { error: { code: 'forbidden', message: 'Not allowed', details: {} } } } }),
{ attach: true },
)
await selectAllAndDelete(wrapper)
expect(toasts.value[0]).toMatchObject({ text: 'Not allowed', tone: 'danger' })
})
it('toasts the generic failure when the bulk delete cannot reach the server', async () => {
const { wrapper } = await mountApp(
'/acme/demo/widgets',
routes({
[`POST ${BASE}/bulk-delete`]: () => {
throw new TypeError('offline')
},
}),
{ attach: true },
)
await selectAllAndDelete(wrapper)
expect(toasts.value[0]).toMatchObject({ text: 'Nie udało się usunąć zaznaczonych rekordów.', tone: 'danger' })
expect(wrapper.find('[data-action="delete"]').attributes('disabled')).toBeUndefined()
})
})

View File

@@ -0,0 +1,84 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import Pagination from '../../src/components/list/Pagination.vue'
import type { ListMeta } from '../../src/api/types'
import { resetState } from '../helpers'
function meta(page: number, lastPage: number, total = lastPage * 20, perPage = 20): ListMeta {
return { page, last_page: lastPage, total, per_page: perPage }
}
function pager(page: number, lastPage: number) {
const wrapper = mount(Pagination, { props: { meta: meta(page, lastPage), loading: false, perPageOptions: [20], perPage: 20 } })
return wrapper
.findAll('nav > *')
.filter((element) => !['prev', 'next'].includes(element.attributes('data-page') ?? ''))
.map((element) => element.text())
.join(' ')
}
beforeEach(() => {
resetState()
})
describe('pagination', () => {
it('shows the range of the page and a labelled pager', () => {
const wrapper = mount(Pagination, { props: { meta: meta(2, 3, 45), loading: false, perPageOptions: [20], perPage: 20 } })
expect(wrapper.find('[data-range]').text()).toBe('21–40 z 45')
expect(wrapper.find('nav').attributes('aria-label')).toBe('Stronicowanie')
expect(wrapper.find('[data-page="2"]').attributes('aria-current')).toBe('page')
expect(wrapper.find('[data-page="1"]').attributes('aria-current')).toBeUndefined()
})
it.each([
[1, 1, '1'],
[1, 5, '1 2 3 … 5'],
[3, 5, '1 2 3 4 5'],
[5, 10, '1 … 4 5 6 … 10'],
[10, 10, '1 … 8 9 10'],
[2, 3, '1 2 3'],
])('page %i of %i shows %s', (page, last, expected) => {
expect(pager(page, last)).toBe(expected)
})
it('disables prev on the first and next on the last page and emits pages', async () => {
const wrapper = mount(Pagination, { props: { meta: meta(1, 3), loading: false, perPageOptions: [20], perPage: 20 } })
expect(wrapper.find('[data-page="prev"]').attributes('disabled')).toBeDefined()
expect(wrapper.find('[data-page="next"]').attributes('disabled')).toBeUndefined()
await wrapper.find('[data-page="next"]').trigger('click')
await wrapper.find('[data-page="3"]').trigger('click')
await wrapper.find('[data-page="1"]').trigger('click')
expect(wrapper.emitted('page')).toEqual([[2], [3]])
await wrapper.setProps({ meta: meta(3, 3) })
expect(wrapper.find('[data-page="next"]').attributes('disabled')).toBeDefined()
await wrapper.find('[data-page="prev"]').trigger('click')
expect(wrapper.emitted('page')!.at(-1)).toEqual([2])
})
it('shows the plural zero-results message and no pager without rows', () => {
const wrapper = mount(Pagination, { props: { meta: meta(1, 0, 0), loading: false, perPageOptions: [20], perPage: 20 } })
expect(wrapper.find('[data-range]').text()).toBe('0 wyników')
expect(wrapper.find('nav').exists()).toBe(false)
const none = mount(Pagination, { props: { meta: null, loading: false, perPageOptions: [20], perPage: 20 } })
expect(none.find('[data-range]').text()).toBe('0 wyników')
})
it('shows the loading text and hides the pager while loading', () => {
const wrapper = mount(Pagination, { props: { meta: meta(1, 2), loading: true, perPageOptions: [20], perPage: 20 } })
expect(wrapper.find('[data-range]').text()).toBe('Wczytywanie…')
expect(wrapper.find('nav').exists()).toBe(false)
})
it('offers the per-page select only with more than one option and emits a valid choice', async () => {
const one = mount(Pagination, { props: { meta: meta(1, 2), loading: false, perPageOptions: [20], perPage: 20 } })
expect(one.find('[data-per-page]').exists()).toBe(false)
const wrapper = mount(Pagination, { props: { meta: meta(1, 2), loading: false, perPageOptions: [10, 20, 50], perPage: 20 } })
const select = wrapper.find('[data-per-page]')
expect(select.findAll('option').map((option) => option.text())).toEqual(['10', '20', '50'])
expect((select.element as HTMLSelectElement).value).toBe('20')
await select.setValue('50')
expect(wrapper.emitted('perPage')).toEqual([[50]])
})
})