Files
Kanban/src/stores/tasks.spec.ts
T
chenshoulin 4e064e82ea feat: 个人轻量看板工具 MVP
从零实现一个 local-first 的个人任务看板,数据只存在浏览器本地,不依赖后端。

功能
- 固定三列看板(待办 / 进行中 / 已完成),支持拖拽改状态与列内排序
- 快速新增、详情编辑、删除撤销、已完成归档
- 搜索与筛选(关键词 / 优先级 / 标签 / 今日到期 / 逾期)
- 今日视图:按紧急度分组(逾期 > 今天到期 > 进行中)+ 统计卡片
- 导出导入 JSON;启动时自动留快照,保留最近 3 份
- 键盘快捷键(N / 「/」/ ? / Esc)
- PWA:可安装、离线可用、更新提示

技术要点
- 持久化采用 TaskRepository 端口 + 整份快照读写,schemaVersion 迁移表
  显式登记升级规则(当前 v2,v1 -> v2 为归档功能补 archivedAt)
- 同列排序用相邻中点插入,间距不足时按列 normalize,避免浮点精度耗尽
- 拖拽只消费 Sortable 的事件,store 保持唯一数据源,不接管数据
- 数据损坏时先备份到 *.corrupt.<时间戳> 再报错,不覆盖用户数据
- 引入 vue-router 拆分看板与今日两个视图(见 docs/adr.md ADR-005)

验证
- 单元测试 437 个(33 个文件),行覆盖率 96.8%
- 端到端测试 23 个(Playwright),含真实拖拽与断网离线场景
- npm run build 通过,产物可静态托管

注:部署时需配置 SPA 回落,否则直接访问 /today 会 404(详见 README)。
2026-10-18 11:59:49 +08:00

447 lines
15 KiB
TypeScript

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)
})
})