Files
Kanban/src/components/BoardColumn.spec.ts
T
chenshoulin 2d6e24dc91 feat(board): 支持批量添加与批量删除任务
- 批量添加面板:一行一条,整批共用目标列、优先级、标签与截止日期
- 快速新增支持粘贴多行文本,直接拆成多条任务
- 多选模式支持全选可见任务、整批删除,撤销时连原顺序一起还原
- 批量创建与删除走 store 的批量方法,一次落盘、一次广播
- 补充单元测试与 E2E 用例
2026-10-19 17:21:42 +08:00

261 lines
9.0 KiB
TypeScript

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> = {}): 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,
...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)
})
})
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<string, unknown>
expect(options.group).toBe('kanban-board')
expect(options.filter).toBe('button')
})
})
describe('BoardColumn 多选模式', () => {
function mountSelectable(tasks: Task[], selectedIds: Set<string>) {
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)
})
})