- 批量添加面板:一行一条,整批共用目标列、优先级、标签与截止日期 - 快速新增支持粘贴多行文本,直接拆成多条任务 - 多选模式支持全选可见任务、整批删除,撤销时连原顺序一起还原 - 批量创建与删除走 store 的批量方法,一次落盘、一次广播 - 补充单元测试与 E2E 用例
261 lines
9.0 KiB
TypeScript
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)
|
|
})
|
|
})
|