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