315 lines
12 KiB
TypeScript
315 lines
12 KiB
TypeScript
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
|
import { h } from 'vue'
|
|
import { mount } from '@vue/test-utils'
|
|
import type { DefineComponent } from 'vue'
|
|
import DataTable from './DataTable.vue'
|
|
|
|
type DataTableProps = {
|
|
id?: string
|
|
columns?: { key: string; label: string }[]
|
|
items?: Record<string, unknown>[]
|
|
totalItems?: number
|
|
page?: number
|
|
perPage?: number
|
|
perPageOptions?: number[]
|
|
rowClickable?: boolean
|
|
tableClass?: string
|
|
emptyMessage?: string
|
|
}
|
|
|
|
const DataTableForTest = DataTable as DefineComponent<DataTableProps>
|
|
|
|
const defaultColumns = [
|
|
{ key: 'nom', label: 'Nom' },
|
|
{ key: 'ville', label: 'Ville' },
|
|
]
|
|
|
|
const defaultItems = [
|
|
{ nom: 'Dupont', ville: 'Paris' },
|
|
{ nom: 'Martin', ville: 'Lyon' },
|
|
{ nom: 'Bernard', ville: 'Marseille' },
|
|
]
|
|
|
|
function mountComponent(props: DataTableProps = {}, slots?: Record<string, unknown>) {
|
|
return mount(DataTableForTest, {
|
|
props: {
|
|
columns: defaultColumns,
|
|
items: defaultItems,
|
|
totalItems: 3,
|
|
...props,
|
|
},
|
|
slots,
|
|
global: {
|
|
stubs: {
|
|
MalioSelect: {
|
|
name: 'MalioSelect',
|
|
template: '<div data-test="malio-select"><slot /></div>',
|
|
props: ['modelValue', 'options'],
|
|
emits: ['update:modelValue'],
|
|
},
|
|
MalioButton: {
|
|
template: '<button v-bind="$attrs" :disabled="disabled" @click="$emit(\'click\', $event)"><slot>{{ label }}</slot></button>',
|
|
props: ['label', 'disabled', 'variant', 'buttonClass'],
|
|
emits: ['click'],
|
|
inheritAttrs: true,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
describe('MalioDataTable', () => {
|
|
describe('Table rendering', () => {
|
|
it('renders column headers as text when no header slot', () => {
|
|
const wrapper = mountComponent()
|
|
const headers = wrapper.findAll('th')
|
|
expect(headers).toHaveLength(2)
|
|
expect(headers[0].text()).toBe('Nom')
|
|
expect(headers[1].text()).toBe('Ville')
|
|
})
|
|
|
|
it('renders header slot when provided', () => {
|
|
const wrapper = mountComponent({}, {
|
|
'header-nom': '<input data-test="filter-nom" placeholder="Nom" />',
|
|
})
|
|
expect(wrapper.find('[data-test="filter-nom"]').exists()).toBe(true)
|
|
})
|
|
|
|
it('renders items as rows', () => {
|
|
const wrapper = mountComponent()
|
|
const rows = wrapper.findAll('[data-test="row"]')
|
|
expect(rows).toHaveLength(3)
|
|
expect(rows[0].text()).toContain('Dupont')
|
|
expect(rows[0].text()).toContain('Paris')
|
|
})
|
|
|
|
it('renders cell slot when provided', () => {
|
|
const wrapper = mountComponent({}, {
|
|
'cell-nom': ({ item }: { item: Record<string, unknown> }) => h('strong', String(item.nom)),
|
|
})
|
|
const firstRow = wrapper.findAll('[data-test="row"]')[0]
|
|
expect(firstRow.find('strong').text()).toBe('Dupont')
|
|
})
|
|
|
|
it('renders empty message when items is empty', () => {
|
|
const wrapper = mountComponent({ items: [], totalItems: 0 })
|
|
expect(wrapper.find('[data-test="empty-row"]').text()).toBe('Aucune donnée')
|
|
})
|
|
|
|
it('renders custom empty message', () => {
|
|
const wrapper = mountComponent({ items: [], totalItems: 0, emptyMessage: 'Rien ici' })
|
|
expect(wrapper.find('[data-test="empty-row"]').text()).toBe('Rien ici')
|
|
})
|
|
|
|
it('renders empty slot when provided', () => {
|
|
const wrapper = mountComponent(
|
|
{ items: [], totalItems: 0 },
|
|
{ empty: '<p data-test="custom-empty">Vide</p>' },
|
|
)
|
|
expect(wrapper.find('[data-test="custom-empty"]').text()).toBe('Vide')
|
|
})
|
|
|
|
it('empty row has colspan equal to columns length', () => {
|
|
const wrapper = mountComponent({ items: [], totalItems: 0 })
|
|
const td = wrapper.find('[data-test="empty-row"] td')
|
|
expect(td.attributes('colspan')).toBe('2')
|
|
})
|
|
})
|
|
|
|
describe('Row click', () => {
|
|
it('emits row-click with item on row click', async () => {
|
|
const wrapper = mountComponent()
|
|
await wrapper.findAll('[data-test="row"]')[0].trigger('click')
|
|
expect(wrapper.emitted('row-click')?.[0]).toEqual([{ nom: 'Dupont', ville: 'Paris' }])
|
|
})
|
|
|
|
it('emits row-click on Enter key', async () => {
|
|
const wrapper = mountComponent()
|
|
await wrapper.findAll('[data-test="row"]')[0].trigger('keydown.enter')
|
|
expect(wrapper.emitted('row-click')?.[0]).toEqual([{ nom: 'Dupont', ville: 'Paris' }])
|
|
})
|
|
|
|
it('emits row-click on Space key', async () => {
|
|
const wrapper = mountComponent()
|
|
await wrapper.findAll('[data-test="row"]')[0].trigger('keydown.space')
|
|
expect(wrapper.emitted('row-click')?.[0]).toEqual([{ nom: 'Dupont', ville: 'Paris' }])
|
|
})
|
|
|
|
it('rows have tabindex when clickable', () => {
|
|
const wrapper = mountComponent()
|
|
expect(wrapper.findAll('[data-test="row"]')[0].attributes('tabindex')).toBe('0')
|
|
})
|
|
|
|
it('rows have cursor-pointer when clickable', () => {
|
|
const wrapper = mountComponent()
|
|
expect(wrapper.findAll('[data-test="row"]')[0].classes()).toContain('cursor-pointer')
|
|
})
|
|
|
|
it('rows are not clickable when rowClickable is false', async () => {
|
|
const wrapper = mountComponent({ rowClickable: false })
|
|
await wrapper.findAll('[data-test="row"]')[0].trigger('click')
|
|
expect(wrapper.emitted('row-click')).toBeUndefined()
|
|
})
|
|
|
|
it('rows have no tabindex when not clickable', () => {
|
|
const wrapper = mountComponent({ rowClickable: false })
|
|
expect(wrapper.findAll('[data-test="row"]')[0].attributes('tabindex')).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('Accessibility', () => {
|
|
it('th elements have scope="col"', () => {
|
|
const wrapper = mountComponent()
|
|
const ths = wrapper.findAll('th')
|
|
ths.forEach(th => {
|
|
expect(th.attributes('scope')).toBe('col')
|
|
})
|
|
})
|
|
|
|
it('generates an id when not provided', () => {
|
|
const wrapper = mountComponent()
|
|
const id = wrapper.find('div').attributes('id')
|
|
expect(id).toMatch(/^malio-datatable-/)
|
|
})
|
|
|
|
it('uses custom id when provided', () => {
|
|
const wrapper = mountComponent({ id: 'my-table' })
|
|
expect(wrapper.find('div').attributes('id')).toBe('my-table')
|
|
})
|
|
})
|
|
|
|
describe('Pagination', () => {
|
|
it('hides pagination when totalItems is 0', () => {
|
|
const wrapper = mountComponent({ items: [], totalItems: 0 })
|
|
expect(wrapper.find('[data-test="pagination"]').exists()).toBe(false)
|
|
})
|
|
|
|
it('shows pagination when totalItems > 0', () => {
|
|
const wrapper = mountComponent({ totalItems: 30 })
|
|
expect(wrapper.find('[data-test="pagination"]').exists()).toBe(true)
|
|
})
|
|
|
|
it('Prev button is disabled on page 1', () => {
|
|
const wrapper = mountComponent({ totalItems: 50, perPage: 10, page: 1 })
|
|
expect(wrapper.find('[data-test="prev-button"]').attributes('disabled')).toBeDefined()
|
|
})
|
|
|
|
it('Next button is disabled on last page', () => {
|
|
const wrapper = mountComponent({ totalItems: 50, perPage: 10, page: 5 })
|
|
expect(wrapper.find('[data-test="next-button"]').attributes('disabled')).toBeDefined()
|
|
})
|
|
|
|
it('Prev button emits update:page with page - 1', async () => {
|
|
const wrapper = mountComponent({ totalItems: 50, perPage: 10, page: 3 })
|
|
await wrapper.find('[data-test="prev-button"]').trigger('click')
|
|
expect(wrapper.emitted('update:page')?.[0]).toEqual([2])
|
|
})
|
|
|
|
it('Next button emits update:page with page + 1', async () => {
|
|
const wrapper = mountComponent({ totalItems: 50, perPage: 10, page: 3 })
|
|
await wrapper.find('[data-test="next-button"]').trigger('click')
|
|
expect(wrapper.emitted('update:page')?.[0]).toEqual([4])
|
|
})
|
|
|
|
it('pagination nav has aria-label', () => {
|
|
const wrapper = mountComponent({ totalItems: 30 })
|
|
expect(wrapper.find('[data-test="pagination-nav"]').attributes('aria-label')).toBe('Pagination')
|
|
})
|
|
|
|
it('Prev button has aria-label "Page précédente"', () => {
|
|
const wrapper = mountComponent({ totalItems: 30 })
|
|
expect(wrapper.find('[data-test="prev-button"]').attributes('aria-label')).toBe('Page précédente')
|
|
})
|
|
|
|
it('Next button has aria-label "Page suivante"', () => {
|
|
const wrapper = mountComponent({ totalItems: 30 })
|
|
expect(wrapper.find('[data-test="next-button"]').attributes('aria-label')).toBe('Page suivante')
|
|
})
|
|
})
|
|
|
|
describe('Pagination — saut de page (champ)', () => {
|
|
beforeEach(() => { vi.useFakeTimers() })
|
|
afterEach(() => { vi.runOnlyPendingTimers(); vi.useRealTimers() })
|
|
|
|
it('affiche la page courante et le total dans le champ', () => {
|
|
const wrapper = mountComponent({ totalItems: 310, perPage: 10, page: 16 })
|
|
expect((wrapper.find('[data-test="page-input"]').element as HTMLInputElement).value).toBe('16')
|
|
expect(wrapper.find('[data-test="total-pages"]').text()).toBe('31')
|
|
})
|
|
|
|
it('émet update:page après le debounce pour une valeur valide', async () => {
|
|
const wrapper = mountComponent({ totalItems: 310, perPage: 10, page: 1 })
|
|
const input = wrapper.find('[data-test="page-input"]')
|
|
await input.setValue('16')
|
|
expect(wrapper.emitted('update:page')).toBeUndefined()
|
|
vi.advanceTimersByTime(400)
|
|
expect(wrapper.emitted('update:page')?.at(-1)).toEqual([16])
|
|
})
|
|
|
|
it('n\'émet pas avant la fin du debounce', async () => {
|
|
const wrapper = mountComponent({ totalItems: 310, perPage: 10, page: 1 })
|
|
await wrapper.find('[data-test="page-input"]').setValue('16')
|
|
vi.advanceTimersByTime(399)
|
|
expect(wrapper.emitted('update:page')).toBeUndefined()
|
|
})
|
|
|
|
it('Entrée applique immédiatement', async () => {
|
|
const wrapper = mountComponent({ totalItems: 310, perPage: 10, page: 1 })
|
|
const input = wrapper.find('[data-test="page-input"]')
|
|
await input.setValue('16')
|
|
await input.trigger('keydown.enter')
|
|
expect(wrapper.emitted('update:page')?.at(-1)).toEqual([16])
|
|
})
|
|
|
|
it('clampe une valeur > N à la dernière page (Entrée)', async () => {
|
|
const wrapper = mountComponent({ totalItems: 310, perPage: 10, page: 1 })
|
|
const input = wrapper.find('[data-test="page-input"]')
|
|
await input.setValue('50')
|
|
await input.trigger('keydown.enter')
|
|
expect(wrapper.emitted('update:page')?.at(-1)).toEqual([31])
|
|
})
|
|
|
|
it('restaure la page courante quand le champ est vidé au blur', async () => {
|
|
const wrapper = mountComponent({ totalItems: 310, perPage: 10, page: 5 })
|
|
const input = wrapper.find('[data-test="page-input"]')
|
|
await input.setValue('')
|
|
await input.trigger('blur')
|
|
expect(wrapper.emitted('update:page')).toBeUndefined()
|
|
expect((input.element as HTMLInputElement).value).toBe('5')
|
|
})
|
|
|
|
it('n\'émet pas pour 0 et restaure la page courante (Entrée)', async () => {
|
|
const wrapper = mountComponent({ totalItems: 310, perPage: 10, page: 5 })
|
|
const input = wrapper.find('[data-test="page-input"]')
|
|
await input.setValue('0')
|
|
await input.trigger('keydown.enter')
|
|
expect(wrapper.emitted('update:page')).toBeUndefined()
|
|
expect((input.element as HTMLInputElement).value).toBe('5')
|
|
})
|
|
|
|
it('retire les caractères non numériques à la frappe', async () => {
|
|
const wrapper = mountComponent({ totalItems: 310, perPage: 10, page: 1 })
|
|
const input = wrapper.find('[data-test="page-input"]')
|
|
await input.setValue('1a2b')
|
|
expect((input.element as HTMLInputElement).value).toBe('12')
|
|
})
|
|
|
|
it('resynchronise le champ quand la prop page change', async () => {
|
|
const wrapper = mountComponent({ totalItems: 310, perPage: 10, page: 1 })
|
|
await wrapper.setProps({ page: 7 })
|
|
expect((wrapper.find('[data-test="page-input"]').element as HTMLInputElement).value).toBe('7')
|
|
})
|
|
})
|
|
|
|
describe('Per-page selector', () => {
|
|
it('emits update:per-page and reset page to 1 on change', async () => {
|
|
const wrapper = mountComponent({ totalItems: 100, perPage: 10, page: 5 })
|
|
const select = wrapper.findComponent({ name: 'MalioSelect' })
|
|
select.vm.$emit('update:modelValue', 25)
|
|
await wrapper.vm.$nextTick()
|
|
expect(wrapper.emitted('update:per-page')?.[0]).toEqual([25])
|
|
expect(wrapper.emitted('update:page')?.[0]).toEqual([1])
|
|
})
|
|
})
|
|
})
|