import { mount } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import { useDraggable } from 'vue-draggable-plus' import { describe, expect, it, vi } from 'vitest' import { createMemoryTaskRepository } from '@/data/taskRepository' import { useTaskStore } from '@/stores/tasks' import type { ColumnDef, Task } from '@/types/task' import { resolveDrop } from '@/utils/drag' import BoardColumn from './BoardColumn.vue' /** * 屏蔽真实的 Sortable:jsdom 没有布局,真实实例在测试中不起作用, * 而且我们要断言的是「拖拽事件 → emit」这段接线,直接调用库回调更可靠。 */ vi.mock('vue-draggable-plus', () => ({ useDraggable: vi.fn() })) interface DragOptions { onEnd?: (event: unknown) => void onMove?: (event: unknown, originalEvent: unknown) => unknown } /** 取最近一次传给 useDraggable 的配置 */ function lastDragOptions(): DragOptions { const calls = vi.mocked(useDraggable).mock.calls return (calls[calls.length - 1]?.[1] ?? {}) as DragOptions } const TODO_COLUMN: ColumnDef = { id: 'todo', title: '待办' } function makeTask(overrides: Partial = {}): Task { return { id: 'task-1', title: '写方案', description: '', status: 'todo', priority: 2, tags: [], dueDate: null, sortOrder: 1024, createdAt: '2026-10-18T00:00:00.000Z', updatedAt: '2026-10-18T00:00:00.000Z', completedAt: null, archivedAt: null, deletedAt: null, ...overrides, } } function setup(tasks: Task[] = [makeTask()], sortDisabled = false) { const pinia = createPinia() setActivePinia(pinia) const store = useTaskStore() store.init({ repository: createMemoryTaskRepository(tasks), crossTab: false }) const wrapper = mount(BoardColumn, { props: { column: TODO_COLUMN, tasks: store.byColumn.todo, sortDisabled }, global: { plugins: [pinia] }, }) return { store, wrapper } } describe('BoardColumn 渲染', () => { it('渲染列标题与计数', () => { const { wrapper } = setup([makeTask({ id: 'a' }), makeTask({ id: 'b' })]) expect(wrapper.find('h2').text()).toBe('待办') expect(wrapper.find('[data-role="column-count"]').text()).toBe('2') }) it('空列显示空状态', () => { const { wrapper } = setup([]) expect(wrapper.findAll('article')).toHaveLength(0) expect(wrapper.text()).toContain('暂无任务') }) it('列内包含快速新增输入框', () => { const { wrapper } = setup([]) expect(wrapper.find('input[aria-label="添加任务"]').exists()).toBe(true) }) it('已完成列标注排序口径,其它列不标注', () => { const pinia = createPinia() setActivePinia(pinia) const store = useTaskStore() store.init({ repository: createMemoryTaskRepository(), crossTab: false }) const mountWith = (column: ColumnDef) => mount(BoardColumn, { props: { column, tasks: [], sortDisabled: column.id === 'done' }, global: { plugins: [pinia] }, }) expect(mountWith({ id: 'done', title: '已完成' }).find('[data-role="column-sort-hint"]').text()).toBe( '按完成时间倒序', ) expect(mountWith(TODO_COLUMN).find('[data-role="column-sort-hint"]').exists()).toBe(false) }) }) describe('BoardColumn 拖拽契约', () => { it('拖拽容器带 data-column-id', () => { const { wrapper } = setup() expect(wrapper.find('[data-column-id="todo"]').exists()).toBe(true) }) it('卡片带 data-task-id', () => { const { wrapper } = setup([makeTask({ id: 'task-xyz' })]) expect(wrapper.find('[data-task-id="task-xyz"]').exists()).toBe(true) }) it('渲染出的真实 DOM 能满足 resolveDrop 的解析契约', () => { const { wrapper } = setup([makeTask({ id: 'task-1' }), makeTask({ id: 'task-2' })]) const list = wrapper.find('[data-column-id="todo"]').element as HTMLElement const item = wrapper.find('[data-task-id="task-2"]').element as HTMLElement expect(resolveDrop({ item, to: list, newIndex: 0 })).toEqual({ taskId: 'task-2', toColumn: 'todo', toIndex: 0, }) }) }) describe('BoardColumn 交互', () => { it('点击完成按钮 emit toggle', async () => { const { wrapper } = setup([makeTask({ id: 'task-1' })]) await wrapper.find('article button').trigger('click') expect(wrapper.emitted('toggle')).toEqual([['task-1']]) }) it('点击卡片 emit open', async () => { const { wrapper } = setup([makeTask({ id: 'task-1' })]) await wrapper.find('article').trigger('click') expect(wrapper.emitted('open')).toEqual([['task-1']]) }) it('通过界面新增任务会写入 store', async () => { const { store, wrapper } = setup([]) await wrapper.find('input[aria-label="添加任务"]').setValue('新任务') await wrapper.find('form').trigger('submit') expect(store.byColumn.todo.map((task) => task.title)).toEqual(['新任务']) }) }) describe('BoardColumn 长列表', () => { function makeMany(count: number): Task[] { return Array.from({ length: count }, (_, index) => makeTask({ id: `task-${index}`, title: `任务 ${index}`, sortOrder: (index + 1) * 1024 }), ) } it('单列 200 张卡片全部渲染且计数正确', () => { const { wrapper } = setup(makeMany(200)) expect(wrapper.findAll('article')).toHaveLength(200) expect(wrapper.find('[data-role="column-count"]').text()).toBe('200') }) it('200 张卡片仍保持 sortOrder 升序渲染', () => { const { wrapper } = setup(makeMany(200)) const titles = wrapper.findAll('article h3').map((node) => node.text()) expect(titles[0]).toBe('任务 0') expect(titles[199]).toBe('任务 199') }) it('200 张卡片时仍能通过界面新增任务', async () => { const { store, wrapper } = setup(makeMany(200)) await wrapper.find('input[aria-label="添加任务"]').setValue('插到最前') await wrapper.find('form').trigger('submit') expect(store.byColumn.todo).toHaveLength(201) expect(store.byColumn.todo[0]?.title).toBe('插到最前') }) }) describe('BoardColumn 拖拽事件接线', () => { it('onEnd 解析落点并 emit move', () => { const { wrapper } = setup([makeTask({ id: 'task-1' })]) const item = wrapper.find('[data-task-id="task-1"]').element const to = wrapper.find('[data-column-id="todo"]').element lastDragOptions().onEnd?.({ item, to, newIndex: 0 }) expect(wrapper.emitted('move')).toEqual([[{ id: 'task-1', toColumn: 'todo', toIndex: 0 }]]) }) it('onEnd 缺少任务信息时不 emit', () => { const { wrapper } = setup() const to = wrapper.find('[data-column-id="todo"]').element lastDragOptions().onEnd?.({ item: document.createElement('article'), to, newIndex: 0 }) expect(wrapper.emitted('move')).toBeUndefined() }) it('onEnd 下标缺失时不 emit', () => { const { wrapper } = setup([makeTask({ id: 'task-1' })]) const item = wrapper.find('[data-task-id="task-1"]').element const to = wrapper.find('[data-column-id="todo"]').element lastDragOptions().onEnd?.({ item, to }) expect(wrapper.emitted('move')).toBeUndefined() }) it('列内为全量渲染时 onMove 一律允许', () => { const { wrapper } = setup([makeTask()], false) const same = wrapper.find('[data-column-id="todo"]').element const result = lastDragOptions().onMove?.({ from: same, to: same }, new Event('move')) expect(result).toBe(true) }) it('sortDisabled 时 onMove 禁止列内排序但允许跨列', () => { const { wrapper } = setup([makeTask()], true) const same = wrapper.find('[data-column-id="todo"]').element const other = document.createElement('div') const onMove = lastDragOptions().onMove expect(onMove?.({ from: same, to: same }, new Event('move'))).toBe(false) expect(onMove?.({ from: same, to: other }, new Event('move'))).toBe(true) }) it('传给 useDraggable 的分组与动画配置正确', () => { setup() const calls = vi.mocked(useDraggable).mock.calls const options = calls[calls.length - 1]?.[1] as Record expect(options.group).toBe('kanban-board') expect(options.filter).toBe('button') }) }) describe('BoardColumn 多选模式', () => { function mountSelectable(tasks: Task[], selectedIds: Set) { const pinia = createPinia() setActivePinia(pinia) const store = useTaskStore() store.init({ repository: createMemoryTaskRepository(tasks), crossTab: false }) const wrapper = mount(BoardColumn, { props: { column: TODO_COLUMN, tasks: store.byColumn.todo, selectable: true, selectedIds }, global: { plugins: [pinia] }, }) return { store, wrapper } } it('卡片变为可勾选,selectedIds 决定勾选态', () => { const { wrapper } = mountSelectable( [makeTask({ id: 'task-1' }), makeTask({ id: 'task-2' })], new Set(['task-2']), ) expect(wrapper.find('[data-task-id="task-1"]').attributes('aria-checked')).toBe('false') expect(wrapper.find('[data-task-id="task-2"]').attributes('aria-checked')).toBe('true') }) it('点击卡片 emit select 并带上 id', async () => { const { wrapper } = mountSelectable([makeTask({ id: 'task-1' })], new Set()) await wrapper.find('article').trigger('click') expect(wrapper.emitted('select')).toEqual([['task-1']]) expect(wrapper.emitted('open')).toBeUndefined() }) it('多选时禁止拖拽(同列与跨列都不允许)', () => { const { wrapper } = mountSelectable([makeTask()], new Set()) const same = wrapper.find('[data-column-id="todo"]').element const other = document.createElement('div') const onMove = lastDragOptions().onMove expect(onMove?.({ from: same, to: same }, new Event('move'))).toBe(false) expect(onMove?.({ from: same, to: other }, new Event('move'))).toBe(false) }) })