Files
Kanban/src/components/BackupPanel.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

367 lines
12 KiB
TypeScript

import { flushPromises, mount } from '@vue/test-utils'
import type { VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createMemoryTaskRepository } from '@/data/taskRepository'
import { useTaskStore } from '@/stores/tasks'
import BackupPanel from './BackupPanel.vue'
vi.mock('@/utils/exportFile', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/utils/exportFile')>()
return { ...actual, downloadText: vi.fn() }
})
const { downloadText } = await import('@/utils/exportFile')
function makePayload(titles: string[]): string {
return JSON.stringify({
schemaVersion: 1,
tasks: titles.map((title, index) => ({
id: `imported-${index}`,
title,
status: 'todo',
priority: 2,
sortOrder: (index + 1) * 1024,
})),
})
}
async function setup(open = true) {
const pinia = createPinia()
setActivePinia(pinia)
const store = useTaskStore()
const repository = createMemoryTaskRepository()
store.init({ repository, crossTab: false })
store.createTask({ title: '原有任务' })
const wrapper = mount(BackupPanel, {
props: { open },
global: { plugins: [pinia], stubs: { teleport: true } },
attachTo: document.body,
})
return { store, wrapper }
}
async function chooseFile(wrapper: VueWrapper, content: string, fileName = 'backup.json') {
const input = wrapper.find('input[type="file"]')
const file = new File([content], fileName, { type: 'application/json' })
// jsdom 的 input.files 是只读的,需要直接定义
Object.defineProperty(input.element, 'files', { value: [file], configurable: true })
await input.trigger('change')
await flushPromises()
}
function buttonByText(wrapper: VueWrapper, text: string) {
return wrapper.findAll('button').find((node) => node.text().includes(text))
}
describe('BackupPanel 显示', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.mocked(downloadText).mockClear()
})
it('open 为 false 时不渲染内容', () => {
const wrapper = mount(BackupPanel, {
props: { open: false },
global: { stubs: { teleport: true } },
})
expect(wrapper.text()).not.toContain('导出为 JSON')
})
it('展示数据风险提示', async () => {
const { wrapper } = await setup()
expect(wrapper.text()).toContain('清理浏览器数据会导致任务丢失')
})
})
describe('BackupPanel 导出', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.mocked(downloadText).mockClear()
})
it('点击导出会生成带日期的文件名并写入内容', async () => {
const { store, wrapper } = await setup()
await buttonByText(wrapper, '导出为 JSON')?.trigger('click')
expect(downloadText).toHaveBeenCalledTimes(1)
const [content, fileName] = vi.mocked(downloadText).mock.calls[0] ?? []
expect(fileName).toMatch(/^kanban-\d{4}-\d{2}-\d{2}\.json$/)
expect(content).toContain('原有任务')
expect(wrapper.text()).toContain(`已导出 ${store.tasks.length} 个任务`)
})
})
describe('BackupPanel 导入', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.mocked(downloadText).mockClear()
})
it('选择合法文件后先请求确认,不立即改动数据', async () => {
const { store, wrapper } = await setup()
await chooseFile(wrapper, makePayload(['新任务一', '新任务二']))
expect(wrapper.text()).toContain('导入 2 个任务')
expect(wrapper.text()).toContain('覆盖当前全部 1 个任务')
expect(store.tasks.map((task) => task.title)).toEqual(['原有任务'])
})
it('确认后覆盖导入', async () => {
const { store, wrapper } = await setup()
await chooseFile(wrapper, makePayload(['新任务一', '新任务二']))
await buttonByText(wrapper, '确认覆盖导入')?.trigger('click')
expect(store.tasks.map((task) => task.title).sort()).toEqual(['新任务一', '新任务二'])
expect(wrapper.text()).toContain('已从 backup.json 导入 2 个任务')
expect(wrapper.text()).toContain('导入前的数据已自动备份')
})
it('取消确认后数据保持不变', async () => {
const { store, wrapper } = await setup()
await chooseFile(wrapper, makePayload(['新任务']))
await buttonByText(wrapper, '取消')?.trigger('click')
expect(wrapper.text()).not.toContain('确认覆盖导入')
expect(store.tasks.map((task) => task.title)).toEqual(['原有任务'])
})
it('非法 JSON 给出明确错误且不破坏现有数据', async () => {
const { store, wrapper } = await setup()
await chooseFile(wrapper, '{broken json')
expect(wrapper.text()).toContain('不是合法的 JSON')
expect(store.tasks.map((task) => task.title)).toEqual(['原有任务'])
})
it('结构不合法时给出错误且不进入确认流程', async () => {
const { store, wrapper } = await setup()
await chooseFile(wrapper, JSON.stringify({ tasks: [{ id: 'broken' }] }))
expect(wrapper.text()).toContain('第 1 条任务格式不合法')
expect(wrapper.text()).not.toContain('确认覆盖导入')
expect(store.tasks.map((task) => task.title)).toEqual(['原有任务'])
})
it('版本过高时拒绝导入', async () => {
const { store, wrapper } = await setup()
await chooseFile(wrapper, JSON.stringify({ schemaVersion: 99, tasks: [] }))
expect(wrapper.text()).toContain('高于当前支持')
expect(store.tasks.map((task) => task.title)).toEqual(['原有任务'])
})
})
describe('BackupPanel 合并导入', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.mocked(downloadText).mockClear()
})
it('预览显示新增 / 更新 / 保持数量', async () => {
const { store, wrapper } = await setup()
const local = store.tasks[0]
const text = JSON.stringify({
schemaVersion: 3,
tasks: [
{
id: local?.id,
title: '更新后的任务',
status: 'todo',
priority: 2,
sortOrder: 1024,
createdAt: local?.createdAt,
updatedAt: '2099-01-01T00:00:00.000Z',
},
{
id: 'new-1',
title: '新增任务',
status: 'todo',
priority: 2,
sortOrder: 2048,
createdAt: '2026-10-01T00:00:00.000Z',
updatedAt: '2026-10-01T00:00:00.000Z',
},
],
})
await chooseFile(wrapper, text)
await wrapper.find('input[value="merge"]').setValue(true)
expect(wrapper.text()).toContain('新增')
expect(wrapper.text()).toContain('更新')
expect(wrapper.text()).toContain('保持')
// 默认仍是覆盖模式,不会静默改成合并
expect(buttonByText(wrapper, '确认合并导入')).toBeDefined()
})
it('确认合并后更新同 id 并新增其余任务', async () => {
const { store, wrapper } = await setup()
const local = store.tasks[0]
const text = JSON.stringify({
schemaVersion: 3,
tasks: [
{
id: local?.id,
title: '更新后的任务',
status: 'todo',
priority: 2,
sortOrder: 1024,
createdAt: local?.createdAt,
updatedAt: '2099-01-01T00:00:00.000Z',
},
{
id: 'new-1',
title: '新增任务',
status: 'todo',
priority: 2,
sortOrder: 2048,
createdAt: '2026-10-01T00:00:00.000Z',
updatedAt: '2026-10-01T00:00:00.000Z',
},
],
})
await chooseFile(wrapper, text)
await wrapper.find('input[value="merge"]').setValue(true)
await buttonByText(wrapper, '确认合并导入')?.trigger('click')
expect(store.activeTasks.map((task) => task.title).sort()).toEqual(
['更新后的任务', '新增任务'].sort(),
)
expect(wrapper.text()).toContain('已合并')
expect(wrapper.text()).toContain('新增 1 个,更新 1 个')
})
it('合并不删除本地独有的任务', async () => {
const { store, wrapper } = await setup()
await chooseFile(wrapper, makePayload(['文件任务']))
await wrapper.find('input[value="merge"]').setValue(true)
await buttonByText(wrapper, '确认合并导入')?.trigger('click')
expect(store.activeTasks.map((task) => task.title).sort()).toEqual(
['原有任务', '文件任务'].sort(),
)
})
it('合并前自动写快照', async () => {
const { store, wrapper } = await setup()
expect(store.snapshots).toHaveLength(0)
await chooseFile(wrapper, makePayload(['文件任务']))
await wrapper.find('input[value="merge"]').setValue(true)
await buttonByText(wrapper, '确认合并导入')?.trigger('click')
expect(store.snapshots).toHaveLength(1)
})
})
describe('BackupPanel 自动备份', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.mocked(downloadText).mockClear()
})
it('启动时写入的快照会出现在列表中', async () => {
const repository = createMemoryTaskRepository()
// 第一次启动:看板为空,不产生快照
setActivePinia(createPinia())
const first = useTaskStore()
first.init({ repository, crossTab: false })
first.createTask({ title: '已有任务' })
// 第二次启动:此时已有数据,写入启动快照
const pinia = createPinia()
setActivePinia(pinia)
const store = useTaskStore()
store.init({ repository, crossTab: false })
expect(store.snapshots).toHaveLength(1)
const wrapper = mount(BackupPanel, {
props: { open: true },
global: { plugins: [pinia], stubs: { teleport: true } },
})
expect(wrapper.text()).not.toContain('暂无自动备份')
expect(buttonByText(wrapper, '恢复')).toBeDefined()
})
it('空看板不写快照,显示暂无备份', async () => {
const pinia = createPinia()
setActivePinia(pinia)
const store = useTaskStore()
store.init({ repository: createMemoryTaskRepository(), crossTab: false })
const wrapper = mount(BackupPanel, {
props: { open: true },
global: { plugins: [pinia], stubs: { teleport: true } },
})
expect(wrapper.text()).toContain('暂无自动备份')
})
it('看板为空时仍会列出已有快照(数据丢失后要能恢复)', async () => {
const repository = createMemoryTaskRepository()
setActivePinia(createPinia())
const first = useTaskStore()
first.init({ repository, crossTab: false })
first.createTask({ title: '快照里的任务' })
// 第二次启动:写入快照
setActivePinia(createPinia())
const second = useTaskStore()
second.init({ repository, crossTab: false })
expect(second.snapshots).toHaveLength(1)
// 清空数据后第三次启动:不能因为看板为空就丢掉快照列表
second.replaceAll([])
const pinia = createPinia()
setActivePinia(pinia)
const third = useTaskStore()
third.init({ repository, crossTab: false })
expect(third.tasks).toHaveLength(0)
expect(third.snapshots).toHaveLength(1)
const wrapper = mount(BackupPanel, {
props: { open: true },
global: { plugins: [pinia], stubs: { teleport: true } },
})
expect(wrapper.text()).not.toContain('暂无自动备份')
expect(buttonByText(wrapper, '恢复')).toBeDefined()
})
it('点击恢复会用快照内容覆盖当前数据', async () => {
const repository = createMemoryTaskRepository()
setActivePinia(createPinia())
const first = useTaskStore()
first.init({ repository, crossTab: false })
first.createTask({ title: '快照里的任务' })
const pinia = createPinia()
setActivePinia(pinia)
const store = useTaskStore()
store.init({ repository, crossTab: false })
expect(store.snapshots).toHaveLength(1)
// 模拟数据丢失
store.replaceAll([])
expect(store.tasks).toHaveLength(0)
const wrapper = mount(BackupPanel, {
props: { open: true },
global: { plugins: [pinia], stubs: { teleport: true } },
})
await buttonByText(wrapper, '恢复')?.trigger('click')
expect(store.tasks.map((task) => task.title)).toEqual(['快照里的任务'])
expect(wrapper.text()).toContain('已从备份恢复 1 个任务')
})
})