import { describe, expect, it } 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[] totalItems?: number page?: number perPage?: number perPageOptions?: number[] rowClickable?: boolean tableClass?: string emptyMessage?: string } const DataTableForTest = DataTable as DefineComponent 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) { return mount(DataTableForTest, { props: { columns: defaultColumns, items: defaultItems, totalItems: 3, ...props, }, slots, global: { stubs: { MalioSelect: { name: 'MalioSelect', template: '
', props: ['modelValue', 'options'], emits: ['update:modelValue'], }, MalioButton: { template: '', 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': '', }) 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 }) => 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: '

Vide

' }, ) 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('renders all pages when totalPages <= 5', () => { const wrapper = mountComponent({ totalItems: 50, perPage: 10 }) for (let i = 1; i <= 5; i++) { expect(wrapper.find(`[data-test="page-${i}"]`).exists()).toBe(true) } }) it('highlights current page', () => { const wrapper = mountComponent({ totalItems: 50, perPage: 10, page: 3 }) expect(wrapper.find('[data-test="page-3"]').attributes('aria-current')).toBe('page') }) it('emits update:page on page button click', async () => { const wrapper = mountComponent({ totalItems: 50, perPage: 10, page: 1 }) await wrapper.find('[data-test="page-3"]').trigger('click') expect(wrapper.emitted('update:page')?.[0]).toEqual([3]) }) 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('shows ellipsis for truncated pages (> 5 pages)', () => { const wrapper = mountComponent({ totalItems: 200, perPage: 10, page: 10 }) const ellipsis = wrapper.findAll('[aria-hidden="true"]') expect(ellipsis.length).toBeGreaterThan(0) expect(ellipsis[0].text()).toBe('…') }) it('always shows first and last page when > 5 pages', () => { const wrapper = mountComponent({ totalItems: 200, perPage: 10, page: 10 }) expect(wrapper.find('[data-test="page-1"]').exists()).toBe(true) expect(wrapper.find('[data-test="page-20"]').exists()).toBe(true) }) it('shows 1 neighbor on each side of current page', () => { const wrapper = mountComponent({ totalItems: 200, perPage: 10, page: 10 }) expect(wrapper.find('[data-test="page-9"]').exists()).toBe(true) expect(wrapper.find('[data-test="page-10"]').exists()).toBe(true) expect(wrapper.find('[data-test="page-11"]').exists()).toBe(true) }) 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('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]) }) }) })