361 lines
12 KiB
TypeScript
361 lines
12 KiB
TypeScript
import { mount } from '@vue/test-utils'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
import DataTable from '../DataTable.vue'
|
|
|
|
vi.mock('vue-i18n', () => ({
|
|
useI18n: () => ({
|
|
t: (key: string) => key
|
|
})
|
|
}))
|
|
|
|
const stubDesktopMatchMedia = () => {
|
|
Object.defineProperty(window, 'matchMedia', {
|
|
writable: true,
|
|
value: vi.fn().mockImplementation((query: string) => ({
|
|
matches: true,
|
|
media: query,
|
|
onchange: null,
|
|
addEventListener: vi.fn(),
|
|
removeEventListener: vi.fn(),
|
|
addListener: vi.fn(),
|
|
removeListener: vi.fn(),
|
|
dispatchEvent: vi.fn()
|
|
}))
|
|
})
|
|
}
|
|
|
|
const stubMobileMatchMedia = () => {
|
|
Object.defineProperty(window, 'matchMedia', {
|
|
writable: true,
|
|
value: vi.fn().mockImplementation((query: string) => ({
|
|
matches: false,
|
|
media: query,
|
|
onchange: null,
|
|
addEventListener: vi.fn(),
|
|
removeEventListener: vi.fn(),
|
|
addListener: vi.fn(),
|
|
removeListener: vi.fn(),
|
|
dispatchEvent: vi.fn()
|
|
}))
|
|
})
|
|
}
|
|
|
|
describe('DataTable', () => {
|
|
beforeEach(() => {
|
|
stubDesktopMatchMedia()
|
|
localStorage.clear()
|
|
})
|
|
|
|
it('renders paired sort arrows and highlights the active direction', async () => {
|
|
const wrapper = mount(DataTable, {
|
|
props: {
|
|
columns: [
|
|
{ key: 'name', label: 'Name', sortable: true },
|
|
{ key: 'created_at', label: 'Created', sortable: true }
|
|
],
|
|
data: [
|
|
{ id: 1, name: 'Beta', created_at: '2026-01-02T00:00:00Z' },
|
|
{ id: 2, name: 'Alpha', created_at: '2026-01-01T00:00:00Z' }
|
|
],
|
|
defaultSortKey: 'name',
|
|
defaultSortOrder: 'asc'
|
|
},
|
|
slots: {
|
|
'header-name': '<span data-test="custom-name-header">Name</span>'
|
|
}
|
|
})
|
|
|
|
await wrapper.vm.$nextTick()
|
|
|
|
const nameHeader = wrapper.findAll('th')[0]
|
|
expect(nameHeader.find('[data-test="custom-name-header"]').exists()).toBe(true)
|
|
expect(nameHeader.attributes('aria-sort')).toBe('ascending')
|
|
expect(nameHeader.findAll('svg')).toHaveLength(2)
|
|
expect(nameHeader.findAll('svg')[0].classes()).toContain('text-primary-600')
|
|
expect(nameHeader.findAll('svg')[1].classes()).toContain('text-gray-300')
|
|
|
|
await nameHeader.trigger('click')
|
|
await wrapper.vm.$nextTick()
|
|
|
|
expect(nameHeader.attributes('aria-sort')).toBe('descending')
|
|
expect(nameHeader.findAll('svg')[0].classes()).toContain('text-gray-300')
|
|
expect(nameHeader.findAll('svg')[1].classes()).toContain('text-primary-600')
|
|
})
|
|
|
|
it('renders every row with no virtual padding spacer for small datasets (virtualization off)', async () => {
|
|
const data = Array.from({ length: 8 }, (_, i) => ({ id: i + 1, name: `Row ${i + 1}` }))
|
|
const wrapper = mount(DataTable, {
|
|
props: {
|
|
columns: [{ key: 'name', label: 'Name' }],
|
|
data
|
|
}
|
|
})
|
|
|
|
await wrapper.vm.$nextTick()
|
|
|
|
// Virtualization is OFF for a small list…
|
|
expect((wrapper.vm as any).shouldVirtualize).toBe(false)
|
|
// …every row is in the DOM…
|
|
expect(wrapper.findAll('tbody tr[data-index]')).toHaveLength(data.length)
|
|
// …and there are no aria-hidden virtual padding spacer rows.
|
|
expect(wrapper.findAll('tbody tr[aria-hidden="true"]')).toHaveLength(0)
|
|
})
|
|
|
|
it('switches to windowed rendering once row count exceeds virtualizeThreshold', async () => {
|
|
const data = Array.from({ length: 12 }, (_, i) => ({ id: i + 1, name: `Row ${i + 1}` }))
|
|
const wrapper = mount(DataTable, {
|
|
props: {
|
|
columns: [{ key: 'name', label: 'Name' }],
|
|
data,
|
|
virtualizeThreshold: 3
|
|
}
|
|
})
|
|
|
|
await wrapper.vm.$nextTick()
|
|
|
|
// Virtualization is ON: the mode-switch decision flipped…
|
|
expect((wrapper.vm as any).shouldVirtualize).toBe(true)
|
|
// …and the virtualizer drives off the full row count.
|
|
const exposed = (wrapper.vm as any).virtualizer
|
|
const instance = exposed?.value ?? exposed
|
|
expect(instance.options.count).toBe(data.length)
|
|
})
|
|
|
|
it('keys the virtualizer size cache by row identity, not index (avoids stale heights on sort/filter)', async () => {
|
|
const data = Array.from({ length: 12 }, (_, i) => ({ id: 100 + i, name: `Row ${i + 1}` }))
|
|
const wrapper = mount(DataTable, {
|
|
props: {
|
|
columns: [{ key: 'name', label: 'Name' }],
|
|
data,
|
|
rowKey: 'id',
|
|
virtualizeThreshold: 3
|
|
}
|
|
})
|
|
|
|
await wrapper.vm.$nextTick()
|
|
|
|
const exposed = (wrapper.vm as any).virtualizer
|
|
const instance = exposed?.value ?? exposed
|
|
// getItemKey must resolve to the row's stable key (id), not the positional index.
|
|
expect(instance.options.getItemKey(0)).toBe(100)
|
|
expect(instance.options.getItemKey(5)).toBe(105)
|
|
})
|
|
|
|
it('clears stale row and element caches when pagination replaces the row ID set', async () => {
|
|
const firstPage = Array.from({ length: 100 }, (_, i) => ({ id: i + 1, name: `First ${i + 1}` }))
|
|
const secondPage = Array.from({ length: 100 }, (_, i) => ({ id: i + 101, name: `Second ${i + 1}` }))
|
|
const wrapper = mount(DataTable, {
|
|
props: {
|
|
columns: [{ key: 'name', label: 'Name' }],
|
|
data: firstPage,
|
|
rowKey: 'id',
|
|
virtualizeThreshold: 1
|
|
}
|
|
})
|
|
|
|
await wrapper.vm.$nextTick()
|
|
|
|
const exposed = (wrapper.vm as any).virtualizer
|
|
const instance = exposed?.value ?? exposed
|
|
const firstPageIDs = firstPage.map(row => row.id)
|
|
;(instance as any).itemSizeCache = new Map(firstPageIDs.map(id => [id, 156]))
|
|
instance.elementsCache.clear()
|
|
for (const id of firstPageIDs) {
|
|
instance.elementsCache.set(id, document.createElement('tr'))
|
|
}
|
|
const measureElementSpy = vi.spyOn(instance, 'measureElement')
|
|
|
|
await wrapper.setProps({ data: secondPage })
|
|
await wrapper.vm.$nextTick()
|
|
|
|
const sizeCache = (instance as any).itemSizeCache as Map<number, number>
|
|
expect(sizeCache.size).toBeLessThanOrEqual(secondPage.length)
|
|
expect(instance.elementsCache.size).toBeLessThanOrEqual(secondPage.length)
|
|
expect(firstPageIDs.some(id => sizeCache.has(id))).toBe(false)
|
|
expect(firstPageIDs.some(id => instance.elementsCache.has(id))).toBe(false)
|
|
expect(measureElementSpy.mock.calls.some(([node]) => node === null)).toBe(true)
|
|
})
|
|
|
|
it('clears stale caches when equal-length pages replace rows without stable keys', async () => {
|
|
const firstPage = Array.from({ length: 12 }, (_, i) => ({ name: `First ${i + 1}` }))
|
|
const secondPage = Array.from({ length: 12 }, (_, i) => ({ name: `Second ${i + 1}` }))
|
|
const wrapper = mount(DataTable, {
|
|
props: {
|
|
columns: [{ key: 'name', label: 'Name' }],
|
|
data: firstPage,
|
|
virtualizeThreshold: 1
|
|
}
|
|
})
|
|
|
|
await wrapper.vm.$nextTick()
|
|
|
|
const exposed = (wrapper.vm as any).virtualizer
|
|
const instance = exposed?.value ?? exposed
|
|
const measureElementSpy = vi.spyOn(instance, 'measureElement')
|
|
|
|
await wrapper.setProps({ data: secondPage })
|
|
await wrapper.vm.$nextTick()
|
|
|
|
expect(measureElementSpy.mock.calls.some(([node]) => node === null)).toBe(true)
|
|
})
|
|
|
|
it('conservatively clears caches when duplicate row-key multiplicity changes', async () => {
|
|
const firstPage = [
|
|
{ id: 1, name: 'First A' },
|
|
{ id: 1, name: 'First B' },
|
|
{ id: 2, name: 'First C' }
|
|
]
|
|
const secondPage = [
|
|
{ id: 1, name: 'Second A' },
|
|
{ id: 2, name: 'Second B' },
|
|
{ id: 2, name: 'Second C' }
|
|
]
|
|
const wrapper = mount(DataTable, {
|
|
props: {
|
|
columns: [{ key: 'name', label: 'Name' }],
|
|
data: firstPage,
|
|
rowKey: 'id',
|
|
virtualizeThreshold: 1
|
|
}
|
|
})
|
|
|
|
await wrapper.vm.$nextTick()
|
|
|
|
const exposed = (wrapper.vm as any).virtualizer
|
|
const instance = exposed?.value ?? exposed
|
|
const measureElementSpy = vi.spyOn(instance, 'measureElement')
|
|
|
|
await wrapper.setProps({ data: secondPage })
|
|
await wrapper.vm.$nextTick()
|
|
|
|
expect(measureElementSpy.mock.calls.some(([node]) => node === null)).toBe(true)
|
|
})
|
|
|
|
it('preserves cache when rows without stable keys only reorder the same objects', async () => {
|
|
const data = Array.from({ length: 12 }, (_, i) => ({ name: `Row ${i + 1}` }))
|
|
const wrapper = mount(DataTable, {
|
|
props: {
|
|
columns: [{ key: 'name', label: 'Name' }],
|
|
data,
|
|
virtualizeThreshold: 1
|
|
}
|
|
})
|
|
|
|
await wrapper.vm.$nextTick()
|
|
|
|
const exposed = (wrapper.vm as any).virtualizer
|
|
const instance = exposed?.value ?? exposed
|
|
const measureSpy = vi.spyOn(instance, 'measure')
|
|
|
|
await wrapper.setProps({ data: [...data].reverse() })
|
|
await wrapper.vm.$nextTick()
|
|
|
|
expect(measureSpy).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('preserves stable row height cache when the same row IDs are only reordered', async () => {
|
|
const data = Array.from({ length: 100 }, (_, i) => ({ id: i + 1, name: `Row ${i + 1}` }))
|
|
const wrapper = mount(DataTable, {
|
|
props: {
|
|
columns: [{ key: 'name', label: 'Name' }],
|
|
data,
|
|
rowKey: 'id',
|
|
virtualizeThreshold: 1
|
|
}
|
|
})
|
|
|
|
await wrapper.vm.$nextTick()
|
|
|
|
const exposed = (wrapper.vm as any).virtualizer
|
|
const instance = exposed?.value ?? exposed
|
|
;(instance as any).itemSizeCache = new Map(data.map(row => [row.id, 156]))
|
|
const measureSpy = vi.spyOn(instance, 'measure')
|
|
|
|
await wrapper.setProps({ data: [...data].reverse() })
|
|
await wrapper.vm.$nextTick()
|
|
|
|
const sizeCache = (instance as any).itemSizeCache as Map<number, number>
|
|
expect(measureSpy).not.toHaveBeenCalled()
|
|
expect(sizeCache.size).toBe(100)
|
|
})
|
|
|
|
it('emits controlled current-page selection while preserving off-page keys', async () => {
|
|
const wrapper = mount(DataTable, {
|
|
props: {
|
|
columns: [{ key: 'name', label: 'Name' }],
|
|
data: [
|
|
{ id: 1, name: 'One' },
|
|
{ id: 2, name: 'Two' }
|
|
],
|
|
rowKey: 'id',
|
|
selectable: true,
|
|
selectedKeys: [99]
|
|
}
|
|
})
|
|
|
|
await wrapper.get('[data-test="select-all"]').setValue(true)
|
|
|
|
const selectedAll = wrapper.emitted('update:selectedKeys')?.at(-1)?.[0]
|
|
expect(selectedAll).toEqual([99, 1, 2])
|
|
|
|
await wrapper.setProps({ selectedKeys: selectedAll as number[] })
|
|
const rowCheckboxes = wrapper.findAll<HTMLInputElement>('[data-test="select-row"]')
|
|
expect(rowCheckboxes.every((checkbox) => checkbox.element.checked)).toBe(true)
|
|
|
|
await rowCheckboxes[0].setValue(false)
|
|
|
|
expect(wrapper.emitted('update:selectedKeys')?.at(-1)?.[0]).toEqual([99, 2])
|
|
expect(wrapper.emitted('selectionChange')?.at(-1)?.[0]).toEqual([99, 2])
|
|
})
|
|
|
|
it('keeps the single usage field shrinkable in a 320px mobile card', () => {
|
|
stubMobileMatchMedia()
|
|
const viewport = document.createElement('div')
|
|
viewport.style.width = '320px'
|
|
document.body.appendChild(viewport)
|
|
const wrapper = mount(DataTable, {
|
|
attachTo: viewport,
|
|
props: {
|
|
columns: [{ key: 'usage', label: 'Usage' }],
|
|
data: [{ id: 1, usage: 'snapshot' }],
|
|
rowKey: 'id'
|
|
},
|
|
slots: {
|
|
'cell-usage': '<div data-test="usage-cell">snapshot</div>'
|
|
}
|
|
})
|
|
|
|
expect(viewport.style.width).toBe('320px')
|
|
expect(wrapper.findAll('[data-field="usage"]')).toHaveLength(1)
|
|
expect(wrapper.find('[data-field="ollama_cloud_usage"]').exists()).toBe(false)
|
|
const field = wrapper.get('[data-field="usage"]')
|
|
expect(field.classes()).toContain('min-w-0')
|
|
expect(field.get('div').classes()).toEqual(expect.arrayContaining(['min-w-0', 'max-w-full']))
|
|
expect(wrapper.findAll('[data-test="usage-cell"]')).toHaveLength(1)
|
|
|
|
wrapper.unmount()
|
|
viewport.remove()
|
|
})
|
|
|
|
it('offers current-page select all in the mobile card layout', async () => {
|
|
stubMobileMatchMedia()
|
|
const wrapper = mount(DataTable, {
|
|
props: {
|
|
columns: [{ key: 'name', label: 'Name' }],
|
|
data: [
|
|
{ id: 1, name: 'One' },
|
|
{ id: 2, name: 'Two' }
|
|
],
|
|
rowKey: 'id',
|
|
selectable: true,
|
|
selectedKeys: [99]
|
|
}
|
|
})
|
|
|
|
await wrapper.get('[data-test="select-all-mobile"]').setValue(true)
|
|
|
|
expect(wrapper.emitted('update:selectedKeys')?.at(-1)?.[0]).toEqual([99, 1, 2])
|
|
})
|
|
})
|