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() 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 个任务') }) })