Files
Kanban/src/components/QuickAddTask.spec.ts
T
chenshoulin 65e98199ba feat: 回收站、批量编辑、合并导入与标签治理
- 数据:Task 增加 deletedAt,schema v2→v3;删除改为软删除,保留 30 天后自动清理
- 回收站:支持恢复、永久删除、二次确认清空;导航栏入口带数量角标
- 批量编辑:多选后统一移动列、改优先级、标签增删、设置截止日、归档
- 合并导入:按 id 匹配、updatedAt 新者胜;覆盖/合并二选一,执行前自动快照
- 标签治理:全局重命名、合并、删除;回收站任务不参与改写
- 看板语义读取统一改用 activeTasks,避免已删除任务泄漏到统计、今日与标签下拉
- UI:回收站图标与备份统一尺寸;批量编辑条统一控件高度并分组
- 测试:705 个单测通过,补充迁移、回收站、批量编辑、合并与标签用例
2026-10-19 18:32:42 +08:00

203 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { describe, expect, it } from 'vitest'
import { createMemoryTaskRepository } from '@/data/taskRepository'
import { useTaskStore } from '@/stores/tasks'
import { useToastStore } from '@/stores/toast'
import type { ColumnId } from '@/types/task'
import QuickAddTask from './QuickAddTask.vue'
function setup(status: ColumnId = 'todo') {
const pinia = createPinia()
setActivePinia(pinia)
const store = useTaskStore()
store.init({ repository: createMemoryTaskRepository(), crossTab: false })
const wrapper = mount(QuickAddTask, {
props: { status },
global: { plugins: [pinia] },
attachTo: document.body,
})
return { store, wrapper }
}
describe('QuickAddTask', () => {
it('提交后创建任务到指定列并清空输入', async () => {
const { store, wrapper } = setup('doing')
const input = wrapper.find('input')
await input.setValue('新任务')
await wrapper.find('form').trigger('submit')
expect(store.tasks.map((task) => task.title)).toEqual(['新任务'])
expect(store.byColumn.doing.map((task) => task.title)).toEqual(['新任务'])
expect((input.element as HTMLInputElement).value).toBe('')
})
it('提交后保持聚焦,便于连续录入', async () => {
const { wrapper } = setup()
const input = wrapper.find('input')
await input.setValue('第一条')
await wrapper.find('form').trigger('submit')
expect(document.activeElement).toBe(input.element)
})
it('连续录入两条,后者位于列首', async () => {
const { store, wrapper } = setup()
const input = wrapper.find('input')
await input.setValue('第一条')
await wrapper.find('form').trigger('submit')
await input.setValue('第二条')
await wrapper.find('form').trigger('submit')
expect(store.byColumn.todo.map((task) => task.title)).toEqual(['第二条', '第一条'])
})
it('空白输入不创建任务', async () => {
const { store, wrapper } = setup()
const input = wrapper.find('input')
await input.setValue(' ')
await wrapper.find('form').trigger('submit')
expect(store.tasks).toHaveLength(0)
await wrapper.find('form').trigger('submit')
expect(store.tasks).toHaveLength(0)
})
it('输入法组合期间回车不提交(中文选词不误触发)', async () => {
const { store, wrapper } = setup()
const input = wrapper.find('input')
await input.trigger('compositionstart')
await input.setValue('zhongwen')
await wrapper.find('form').trigger('submit')
expect(store.tasks).toHaveLength(0)
await input.trigger('compositionend')
await input.setValue('中文')
await wrapper.find('form').trigger('submit')
expect(store.tasks.map((task) => task.title)).toEqual(['中文'])
})
it('标题首尾空白被去除后入库', async () => {
const { store, wrapper } = setup()
await wrapper.find('input').setValue(' 写方案 ')
await wrapper.find('form').trigger('submit')
expect(store.tasks[0]?.title).toBe('写方案')
})
it('输入框带可访问性标签', () => {
const { wrapper } = setup()
expect(wrapper.find('input').attributes('aria-label')).toBe('添加任务')
})
})
/**
* 粘贴多行文本 = 批量添加。
* 这里手工构造 paste 事件:jsdom 没有可用的 Clipboard API,
* 而组件只依赖 `clipboardData.getData('text')` 这一小块接口。
*/
function paste(wrapper: ReturnType<typeof setup>['wrapper'], text: string): boolean {
const event = new Event('paste', { bubbles: true, cancelable: true })
Object.defineProperty(event, 'clipboardData', { value: { getData: () => text } })
wrapper.find('input').element.dispatchEvent(event)
return event.defaultPrevented
}
describe('QuickAddTask 批量粘贴', () => {
it('粘贴多行文本创建多条任务,并保持粘贴顺序', () => {
const { store, wrapper } = setup()
expect(paste(wrapper, '写周报\n评审 PR\n回归测试')).toBe(true)
expect(store.byColumn.todo.map((task) => task.title)).toEqual(['写周报', '评审 PR', '回归测试'])
})
it('粘贴的多条任务位于已有任务之前', () => {
const { store, wrapper } = setup()
store.createTask({ title: '已有任务' })
paste(wrapper, '第一条\n第二条')
expect(store.byColumn.todo.map((task) => task.title)).toEqual(['第一条', '第二条', '已有任务'])
})
it('粘贴到指定列(进行中)', () => {
const { store, wrapper } = setup('doing')
paste(wrapper, 'A\nB')
expect(store.byColumn.doing.map((task) => task.title)).toEqual(['A', 'B'])
expect(store.byColumn.todo).toHaveLength(0)
})
it('单行粘贴不拦截,交回浏览器默认行为', () => {
const { store, wrapper } = setup()
expect(paste(wrapper, '单个任务')).toBe(false)
expect(store.tasks).toHaveLength(0)
})
it('只有空行时不创建任务,也不拦截粘贴', () => {
const { store, wrapper } = setup()
expect(paste(wrapper, ' \n\n')).toBe(false)
expect(store.tasks).toHaveLength(0)
})
it('粘贴时忽略空行与列表符号', () => {
const { store, wrapper } = setup()
paste(wrapper, '- 写周报\n\n- 评审 PR\n')
expect(store.byColumn.todo.map((task) => task.title)).toEqual(['写周报', '评审 PR'])
})
it('粘贴前已输入的草稿并入第一条,不丢失也不残留', async () => {
const { store, wrapper } = setup()
const input = wrapper.find('input')
await input.setValue('手写的一条')
paste(wrapper, '粘贴一\n粘贴二')
expect(store.byColumn.todo.map((task) => task.title)).toEqual([
'手写的一条',
'粘贴一',
'粘贴二',
])
// v-model 的写回要等一轮视图更新
await wrapper.vm.$nextTick()
expect((input.element as HTMLInputElement).value).toBe('')
})
it('批量粘贴后弹出提示并保持聚焦,便于继续录入', () => {
const { wrapper } = setup()
paste(wrapper, 'A\nB')
const toast = useToastStore()
expect(toast.toasts.at(-1)?.message).toBe('已批量添加 2 个任务')
expect(document.activeElement).toBe(wrapper.find('input').element)
})
it('误粘贴时可以整批撤销', () => {
const { store, wrapper } = setup()
paste(wrapper, 'A\nB')
const action = useToastStore().toasts.at(-1)?.action
expect(action?.label).toBe('撤销')
action?.run()
expect(store.activeTasks).toHaveLength(0)
})
})