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