import { createPinia, setActivePinia } from 'pinia' import { describe, expect, it } from 'vitest' import { createMemoryTaskRepository, type TaskRepository } from '@/data/taskRepository' import { useTaskStore } from './tasks' /** * 每次调用都换一个全新的 Pinia 实例,以模拟「重新打开页面」。 * crossTab 关闭,避免测试环境创建 BroadcastChannel。 */ function freshStore(repository: TaskRepository = createMemoryTaskRepository()) { setActivePinia(createPinia()) const store = useTaskStore() store.init({ repository, crossTab: false }) return store } function titles(tasks: readonly { title: string }[]): string[] { return tasks.map((task) => task.title) } describe('初始化', () => { it('从仓库加载已有数据', () => { const repository = createMemoryTaskRepository([ { id: 'existing', title: '已有任务', description: '', status: 'doing', priority: 3, tags: [], dueDate: null, sortOrder: 1024, createdAt: '2026-10-18T00:00:00.000Z', updatedAt: '2026-10-18T00:00:00.000Z', completedAt: null, archivedAt: null, }, ]) const store = freshStore(repository) expect(store.ready).toBe(true) expect(store.tasks).toHaveLength(1) expect(store.byColumn.doing.map((task) => task.title)).toEqual(['已有任务']) }) it('counts 反映各列数量', () => { const store = freshStore() store.createTask({ title: 'A' }) store.createTask({ title: 'B', status: 'doing' }) store.createTask({ title: 'C', status: 'done' }) expect(store.counts).toEqual({ todo: 1, doing: 1, done: 1 }) }) it('重复调用 init 不会清空数据', () => { const store = freshStore() store.createTask({ title: 'A' }) store.init({ crossTab: false }) expect(store.tasks).toHaveLength(1) }) }) describe('快速新增', () => { it('新任务落到待办列首', () => { const store = freshStore() store.createTask({ title: '先建的' }) store.createTask({ title: '后建的' }) expect(titles(store.byColumn.todo)).toEqual(['后建的', '先建的']) }) it('空标题或纯空白不创建任务', () => { const store = freshStore() expect(store.createTask({ title: ' ' })).toBeNull() expect(store.createTask({ title: '' })).toBeNull() expect(store.tasks).toHaveLength(0) }) it('标题首尾空白被去除', () => { const store = freshStore() const task = store.createTask({ title: ' 写方案 ' }) expect(task?.title).toBe('写方案') }) it('标签去重并去除空白项', () => { const store = freshStore() const task = store.createTask({ title: 'A', tags: ['设计', ' 设计 ', '', '前端'] }) expect(task?.tags).toEqual(['设计', '前端']) }) it('直接建到已完成列时写入 completedAt', () => { const store = freshStore() const task = store.createTask({ title: 'A', status: 'done' }) expect(task?.completedAt).not.toBeNull() }) it('默认优先级为中', () => { const store = freshStore() expect(store.createTask({ title: 'A' })?.priority).toBe(2) }) }) describe('编辑', () => { it('修改标题并刷新 updatedAt', async () => { const store = freshStore() const task = store.createTask({ title: '旧标题' }) const before = task?.updatedAt ?? '' await new Promise((resolve) => setTimeout(resolve, 2)) const updated = store.updateTask(task?.id ?? '', { title: '新标题' }) expect(updated?.title).toBe('新标题') expect(updated?.updatedAt).not.toBe(before) }) it('标题被清空时保留原标题', () => { const store = freshStore() const task = store.createTask({ title: '原标题' }) expect(store.updateTask(task?.id ?? '', { title: ' ' })?.title).toBe('原标题') }) it('移到已完成写入 completedAt,移出时清空', () => { const store = freshStore() const task = store.createTask({ title: 'A' }) const id = task?.id ?? '' store.updateTask(id, { status: 'done' }) expect(store.tasks.find((item) => item.id === id)?.completedAt).not.toBeNull() store.updateTask(id, { status: 'todo' }) expect(store.tasks.find((item) => item.id === id)?.completedAt).toBeNull() }) it('状态变更后落到目标列末尾', () => { const store = freshStore() const first = store.createTask({ title: '已在进行中', status: 'doing' }) const moved = store.createTask({ title: '从待办移入' }) store.updateTask(moved?.id ?? '', { status: 'doing' }) expect(titles(store.byColumn.doing)).toEqual(['已在进行中', '从待办移入']) expect(first?.status).toBe('doing') }) it('对不存在的 id 返回 null', () => { const store = freshStore() expect(store.updateTask('missing', { title: 'x' })).toBeNull() }) }) describe('删除与撤销', () => { it('删除后返回被删任务', () => { const store = freshStore() const task = store.createTask({ title: 'A' }) expect(store.removeTask(task?.id ?? '')?.title).toBe('A') expect(store.tasks).toHaveLength(0) }) it('撤销后连原顺序一起恢复', () => { const store = freshStore() store.createTask({ title: 'A' }) const middle = store.createTask({ title: 'B' }) store.createTask({ title: 'C' }) expect(titles(store.byColumn.todo)).toEqual(['C', 'B', 'A']) const removed = store.removeTask(middle?.id ?? '') expect(titles(store.byColumn.todo)).toEqual(['C', 'A']) store.restoreTask(removed!) expect(titles(store.byColumn.todo)).toEqual(['C', 'B', 'A']) }) it('重复撤销不会产生重复任务', () => { const store = freshStore() const task = store.createTask({ title: 'A' }) const removed = store.removeTask(task?.id ?? '') store.restoreTask(removed!) store.restoreTask(removed!) expect(store.tasks).toHaveLength(1) }) }) describe('拖拽移动', () => { it('跨列拖动更新 status 与 completedAt', () => { const store = freshStore() const task = store.createTask({ title: 'A' }) store.moveTask(task?.id ?? '', 'done', 0) const moved = store.tasks.find((item) => item.id === task?.id) expect(moved?.status).toBe('done') expect(moved?.completedAt).not.toBeNull() expect(titles(store.byColumn.done)).toEqual(['A']) expect(store.byColumn.todo).toHaveLength(0) }) it('从已完成拖回待办时清空 completedAt', () => { const store = freshStore() const task = store.createTask({ title: 'A', status: 'done' }) store.moveTask(task?.id ?? '', 'todo', 0) expect(store.tasks.find((item) => item.id === task?.id)?.completedAt).toBeNull() }) it('跨列拖动到指定下标', () => { const store = freshStore() store.createTask({ title: '目标列第一', status: 'doing' }) store.createTask({ title: '目标列第二', status: 'doing' }) const incoming = store.createTask({ title: '移入' }) // 快速新增是「插入列首」,因此目标列当前顺序为:目标列第二, 目标列第一 expect(titles(store.byColumn.doing)).toEqual(['目标列第二', '目标列第一']) store.moveTask(incoming?.id ?? '', 'doing', 1) expect(titles(store.byColumn.doing)).toEqual(['目标列第二', '移入', '目标列第一']) }) it('列内拖动改变顺序', () => { const store = freshStore() const a = store.createTask({ title: 'A' }) store.createTask({ title: 'B' }) store.createTask({ title: 'C' }) expect(titles(store.byColumn.todo)).toEqual(['C', 'B', 'A']) store.moveTask(a?.id ?? '', 'todo', 0) expect(titles(store.byColumn.todo)).toEqual(['A', 'C', 'B']) }) it('列内拖动后重建 store 顺序仍保持(刷新不丢)', () => { const repository = createMemoryTaskRepository() const first = freshStore(repository) const a = first.createTask({ title: 'A' }) first.createTask({ title: 'B' }) first.createTask({ title: 'C' }) first.moveTask(a?.id ?? '', 'todo', 0) const second = freshStore(repository) expect(titles(second.byColumn.todo)).toEqual(['A', 'C', 'B']) }) it('跨列拖动后重建 store,状态与位置都保持', () => { const repository = createMemoryTaskRepository() const first = freshStore(repository) const task = first.createTask({ title: 'A' }) first.moveTask(task?.id ?? '', 'doing', 0) const second = freshStore(repository) expect(second.byColumn.doing.map((item) => item.title)).toEqual(['A']) expect(second.byColumn.todo).toHaveLength(0) }) it('对不存在的 id 无副作用', () => { const store = freshStore() store.createTask({ title: 'A' }) store.moveTask('missing', 'done', 0) expect(titles(store.byColumn.todo)).toEqual(['A']) }) }) describe('持久化', () => { it('新增后重建 store 能读回(刷新不丢)', () => { const repository = createMemoryTaskRepository() const first = freshStore(repository) first.createTask({ title: '持久化任务' }) const second = freshStore(repository) expect(titles(second.tasks)).toEqual(['持久化任务']) }) it('删除后重建 store 不会复活任务', () => { const repository = createMemoryTaskRepository() const first = freshStore(repository) const task = first.createTask({ title: 'A' }) first.removeTask(task?.id ?? '') const second = freshStore(repository) expect(second.tasks).toHaveLength(0) }) }) describe('导出与导入', () => { it('导出后再导入,数据一致', () => { const store = freshStore() store.createTask({ title: 'A', priority: 3, tags: ['x', 'x', 'y'] }) store.createTask({ title: 'B', status: 'doing' }) const json = store.exportJson() const other = freshStore() expect(other.importJson(json)).toBe(2) expect(titles(other.tasks).sort()).toEqual(['A', 'B']) expect(other.tasks.find((task) => task.title === 'A')?.tags).toEqual(['x', 'y']) expect(other.tasks.find((task) => task.title === 'B')?.status).toBe('doing') }) it('导入非法数据时抛错且现有数据不变', () => { const store = freshStore() store.createTask({ title: '保留我' }) expect(() => store.importJson('{"tasks":[{"id":"x"}]}')).toThrow() expect(titles(store.tasks)).toEqual(['保留我']) expect(() => store.importJson('不是 JSON')).toThrow() expect(titles(store.tasks)).toEqual(['保留我']) }) it('导入为覆盖式', () => { const store = freshStore() store.createTask({ title: '旧数据' }) const payload = JSON.stringify({ schemaVersion: 1, tasks: [ { id: 'new', title: '新数据', status: 'todo', priority: 2, sortOrder: 1024, }, ], }) store.importJson(payload) expect(titles(store.tasks)).toEqual(['新数据']) }) }) describe('归档', () => { it('新建任务默认未归档', () => { const store = freshStore() expect(store.createTask({ title: 'A' })?.archivedAt).toBeNull() }) it('归档写入 archivedAt 并返回数量', () => { const store = freshStore() const task = store.createTask({ title: 'A', status: 'done' }) expect(store.archiveTasks([task?.id ?? ''])).toBe(1) expect(store.tasks[0]?.archivedAt).not.toBeNull() }) it('重复归档同一任务不重复计数', () => { const store = freshStore() const task = store.createTask({ title: 'A', status: 'done' }) const id = task?.id ?? '' expect(store.archiveTasks([id])).toBe(1) expect(store.archiveTasks([id])).toBe(0) }) it('对不存在的 id 无副作用', () => { const store = freshStore() store.createTask({ title: 'A' }) expect(store.archiveTasks(['missing'])).toBe(0) expect(store.tasks[0]?.archivedAt).toBeNull() }) it('取消归档清空 archivedAt', () => { const store = freshStore() const task = store.createTask({ title: 'A', status: 'done' }) const id = task?.id ?? '' store.archiveTasks([id]) expect(store.unarchiveTasks([id])).toBe(1) expect(store.tasks[0]?.archivedAt).toBeNull() }) it('取消未归档的任务不计数', () => { const store = freshStore() const task = store.createTask({ title: 'A' }) expect(store.unarchiveTasks([task?.id ?? ''])).toBe(0) }) it('归档状态在重建 store 后保留(刷新不丢)', () => { const repository = createMemoryTaskRepository() const first = freshStore(repository) const task = first.createTask({ title: 'A', status: 'done' }) first.archiveTasks([task?.id ?? '']) const second = freshStore(repository) expect(second.tasks[0]?.archivedAt).not.toBeNull() }) it('批量归档返回实际归档数量', () => { const store = freshStore() const a = store.createTask({ title: 'A', status: 'done' }) const b = store.createTask({ title: 'B', status: 'done' }) const c = store.createTask({ title: 'C' }) const count = store.archiveTasks([a?.id ?? '', b?.id ?? '', c?.id ?? '']) expect(count).toBe(3) }) it('归档不影响任务内容本身', () => { const store = freshStore() const task = store.createTask({ title: '保留标题', description: '保留描述', status: 'done' }) store.archiveTasks([task?.id ?? '']) expect(store.tasks[0]?.title).toBe('保留标题') expect(store.tasks[0]?.description).toBe('保留描述') expect(store.tasks[0]?.status).toBe('done') }) }) describe('规模与稳定性', () => { it('200 个任务时新增、拖动、归档后数据仍自洽', () => { const store = freshStore() for (let index = 0; index < 200; index += 1) { store.createTask({ title: `任务 ${index}` }) } expect(store.byColumn.todo).toHaveLength(200) // 反复把最后一张卡片拖到列首,验证下标换算与精度保护不退化 for (let round = 0; round < 50; round += 1) { const last = store.byColumn.todo[199] if (!last) break store.moveTask(last.id, 'todo', 0) } const column = store.byColumn.todo expect(column).toHaveLength(200) expect(new Set(column.map((task) => task.id)).size).toBe(200) const orders = column.map((task) => task.sortOrder) expect([...orders].sort((a, b) => a - b)).toEqual(orders) for (let index = 1; index < orders.length; index += 1) { const previous = orders[index - 1] ?? 0 const current = orders[index] ?? 0 expect(current - previous).toBeGreaterThan(0) } }) it('批量跨列移动后各列数量守恒', () => { const store = freshStore() const ids: string[] = [] for (let index = 0; index < 60; index += 1) { const task = store.createTask({ title: `任务 ${index}` }) if (task) ids.push(task.id) } for (const [index, id] of ids.entries()) { store.moveTask(id, index % 2 === 0 ? 'doing' : 'done', 0) } expect(store.byColumn.todo).toHaveLength(0) expect(store.byColumn.doing).toHaveLength(30) expect(store.byColumn.done).toHaveLength(30) expect(store.tasks).toHaveLength(60) }) })