从零实现一个 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)。
451 lines
15 KiB
TypeScript
451 lines
15 KiB
TypeScript
import { enableAutoUnmount, mount } from '@vue/test-utils'
|
|
import dayjs from 'dayjs'
|
|
import { createPinia, setActivePinia } from 'pinia'
|
|
import { nextTick } from 'vue'
|
|
import { afterEach, describe, expect, it } from 'vitest'
|
|
|
|
import BoardColumn from '@/components/BoardColumn.vue'
|
|
import { createMemoryTaskRepository } from '@/data/taskRepository'
|
|
import { useFilterStore } from '@/stores/filters'
|
|
import { useTaskStore } from '@/stores/tasks'
|
|
import { useToastStore } from '@/stores/toast'
|
|
|
|
import BoardView from './BoardView.vue'
|
|
|
|
// BoardView 会在 document 上注册快捷键监听,必须逐个卸载避免相互干扰
|
|
enableAutoUnmount(afterEach)
|
|
|
|
function setup() {
|
|
const pinia = createPinia()
|
|
setActivePinia(pinia)
|
|
const store = useTaskStore()
|
|
store.init({ repository: createMemoryTaskRepository(), crossTab: false })
|
|
return { pinia, store }
|
|
}
|
|
|
|
function mountBoard(pinia: ReturnType<typeof createPinia>) {
|
|
// 抽屉内容会被 Teleport 到 body,测试中改为就地渲染以便断言
|
|
return mount(BoardView, {
|
|
global: { plugins: [pinia], stubs: { teleport: true } },
|
|
attachTo: document.body,
|
|
})
|
|
}
|
|
|
|
describe('BoardView 结构', () => {
|
|
it('渲染三个固定列', () => {
|
|
const { pinia } = setup()
|
|
const headings = mountBoard(pinia)
|
|
.findAll('h2')
|
|
.map((node) => node.text())
|
|
expect(headings).toEqual(['待办', '进行中', '已完成'])
|
|
})
|
|
|
|
it('空看板显示空状态', () => {
|
|
const { pinia } = setup()
|
|
const wrapper = mountBoard(pinia)
|
|
expect(wrapper.findAll('article')).toHaveLength(0)
|
|
expect(wrapper.text()).toContain('暂无任务')
|
|
})
|
|
|
|
it('每列显示任务计数', () => {
|
|
const { pinia, store } = setup()
|
|
store.createTask({ title: '待办事项' })
|
|
store.createTask({ title: '进行中事项', status: 'doing' })
|
|
|
|
const counters = mountBoard(pinia)
|
|
.findAll('section header span')
|
|
.map((node) => node.text())
|
|
expect(counters).toEqual(['1', '1', '0'])
|
|
})
|
|
|
|
it('渲染任务标题并显示总数', () => {
|
|
const { pinia, store } = setup()
|
|
store.createTask({ title: '写方案' })
|
|
store.createTask({ title: '做原型', status: 'doing' })
|
|
|
|
const wrapper = mountBoard(pinia)
|
|
expect(wrapper.text()).toContain('写方案')
|
|
expect(wrapper.text()).toContain('做原型')
|
|
expect(wrapper.text()).toContain('共 2 个任务')
|
|
})
|
|
|
|
it('任务按列归位', () => {
|
|
const { pinia, store } = setup()
|
|
store.createTask({ title: '待办事项' })
|
|
store.createTask({ title: '进行中事项', status: 'doing' })
|
|
store.createTask({ title: '已完成事项', status: 'done' })
|
|
|
|
const sections = mountBoard(pinia).findAll('section')
|
|
expect(sections[0]?.text()).toContain('待办事项')
|
|
expect(sections[0]?.text()).not.toContain('进行中事项')
|
|
expect(sections[1]?.text()).toContain('进行中事项')
|
|
expect(sections[2]?.text()).toContain('已完成事项')
|
|
})
|
|
|
|
it('存在存储提示时展示提示条', () => {
|
|
const { pinia, store } = setup()
|
|
store.notice = '本地存储不可用'
|
|
expect(mountBoard(pinia).text()).toContain('本地存储不可用')
|
|
})
|
|
})
|
|
|
|
describe('BoardView 端到端交互', () => {
|
|
it('在界面上输入并回车即可新增任务', async () => {
|
|
const { pinia, store } = setup()
|
|
const wrapper = mountBoard(pinia)
|
|
|
|
const firstColumnInput = wrapper.findAll('section')[0]?.find('input')
|
|
expect(firstColumnInput).toBeDefined()
|
|
|
|
await firstColumnInput?.setValue('界面新增的任务')
|
|
await wrapper.findAll('form')[0]?.trigger('submit')
|
|
|
|
expect(store.byColumn.todo.map((task) => task.title)).toEqual(['界面新增的任务'])
|
|
expect(wrapper.text()).toContain('界面新增的任务')
|
|
})
|
|
|
|
it('点击完成按钮后卡片移动到已完成列', async () => {
|
|
const { pinia, store } = setup()
|
|
store.createTask({ title: '待办任务' })
|
|
const wrapper = mountBoard(pinia)
|
|
|
|
await wrapper.find('article button').trigger('click')
|
|
|
|
expect(store.byColumn.todo).toHaveLength(0)
|
|
expect(store.byColumn.done.map((task) => task.title)).toEqual(['待办任务'])
|
|
expect(wrapper.findAll('section')[2]?.text()).toContain('待办任务')
|
|
})
|
|
|
|
it('再次点击可撤销完成', async () => {
|
|
const { pinia, store } = setup()
|
|
store.createTask({ title: '待办任务' })
|
|
const wrapper = mountBoard(pinia)
|
|
|
|
await wrapper.find('article button').trigger('click')
|
|
await wrapper.find('article button').trigger('click')
|
|
|
|
expect(store.byColumn.todo.map((task) => task.title)).toEqual(['待办任务'])
|
|
expect(store.byColumn.done).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
describe('BoardView 详情抽屉', () => {
|
|
it('初始不渲染详情表单', () => {
|
|
const { pinia, store } = setup()
|
|
store.createTask({ title: '待办任务' })
|
|
expect(mountBoard(pinia).find('input[aria-label="标题"]').exists()).toBe(false)
|
|
})
|
|
|
|
it('点击卡片打开抽屉并回填标题', async () => {
|
|
const { pinia, store } = setup()
|
|
store.createTask({ title: '待办任务' })
|
|
const wrapper = mountBoard(pinia)
|
|
|
|
await wrapper.find('article').trigger('click')
|
|
await nextTick()
|
|
|
|
expect((wrapper.find('input[aria-label="标题"]').element as HTMLInputElement).value).toBe(
|
|
'待办任务',
|
|
)
|
|
})
|
|
|
|
it('保存后看板同步更新', async () => {
|
|
const { pinia, store } = setup()
|
|
store.createTask({ title: '旧标题' })
|
|
const wrapper = mountBoard(pinia)
|
|
|
|
await wrapper.find('article').trigger('click')
|
|
await nextTick()
|
|
await wrapper.find('input[aria-label="标题"]').setValue('新标题')
|
|
|
|
const saveButton = wrapper.findAll('button').find((node) => node.text() === '保存')
|
|
await saveButton?.trigger('click')
|
|
await nextTick()
|
|
|
|
expect(wrapper.text()).toContain('新标题')
|
|
expect(store.tasks[0]?.title).toBe('新标题')
|
|
})
|
|
|
|
it('取消后抽屉关闭且数据不变', async () => {
|
|
const { pinia, store } = setup()
|
|
store.createTask({ title: '原标题' })
|
|
const wrapper = mountBoard(pinia)
|
|
|
|
await wrapper.find('article').trigger('click')
|
|
await nextTick()
|
|
await wrapper.find('input[aria-label="标题"]').setValue('改了一半')
|
|
|
|
const cancelButton = wrapper.findAll('button').find((node) => node.text() === '取消')
|
|
await cancelButton?.trigger('click')
|
|
await nextTick()
|
|
|
|
expect(wrapper.find('input[aria-label="标题"]').exists()).toBe(false)
|
|
expect(store.tasks[0]?.title).toBe('原标题')
|
|
})
|
|
|
|
it('删除后抽屉关闭且任务从看板消失', async () => {
|
|
const { pinia, store } = setup()
|
|
store.createTask({ title: '要删除的任务' })
|
|
const wrapper = mountBoard(pinia)
|
|
|
|
await wrapper.find('article').trigger('click')
|
|
await nextTick()
|
|
|
|
const deleteButton = wrapper.findAll('button').find((node) => node.text() === '删除')
|
|
await deleteButton?.trigger('click')
|
|
await nextTick()
|
|
|
|
expect(store.tasks).toHaveLength(0)
|
|
expect(wrapper.find('input[aria-label="标题"]').exists()).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('BoardView 拖拽接线', () => {
|
|
it('列组件 emit move 时驱动 store 改变状态', async () => {
|
|
const { pinia, store } = setup()
|
|
const task = store.createTask({ title: 'A' })
|
|
const wrapper = mountBoard(pinia)
|
|
|
|
const column = wrapper.findAllComponents(BoardColumn)[0]
|
|
column?.vm.$emit('move', { id: task?.id ?? '', toColumn: 'doing', toIndex: 0 })
|
|
await nextTick()
|
|
|
|
expect(store.byColumn.todo).toHaveLength(0)
|
|
expect(store.byColumn.doing.map((item) => item.title)).toEqual(['A'])
|
|
})
|
|
|
|
it('列内移动事件驱动顺序变化', async () => {
|
|
const { pinia, store } = setup()
|
|
const a = store.createTask({ title: 'A' })
|
|
store.createTask({ title: 'B' })
|
|
store.createTask({ title: 'C' })
|
|
expect(store.byColumn.todo.map((item) => item.title)).toEqual(['C', 'B', 'A'])
|
|
|
|
const wrapper = mountBoard(pinia)
|
|
const column = wrapper.findAllComponents(BoardColumn)[0]
|
|
column?.vm.$emit('move', { id: a?.id ?? '', toColumn: 'todo', toIndex: 0 })
|
|
await nextTick()
|
|
|
|
expect(store.byColumn.todo.map((item) => item.title)).toEqual(['A', 'C', 'B'])
|
|
expect(wrapper.findAll('section')[0]?.text()).toContain('A')
|
|
})
|
|
})
|
|
|
|
describe('BoardView 筛选', () => {
|
|
it('按优先级筛选后只显示匹配卡片,列计数同步变化', async () => {
|
|
const { pinia, store } = setup()
|
|
store.createTask({ title: '高优先级', priority: 3 })
|
|
store.createTask({ title: '低优先级', priority: 1 })
|
|
const wrapper = mountBoard(pinia)
|
|
expect(wrapper.findAll('article')).toHaveLength(2)
|
|
|
|
useFilterStore().togglePriority(3)
|
|
await nextTick()
|
|
|
|
expect(wrapper.findAll('article')).toHaveLength(1)
|
|
expect(wrapper.text()).toContain('高优先级')
|
|
expect(wrapper.text()).not.toContain('低优先级')
|
|
expect(wrapper.findAll('section header span')[0]?.text()).toBe('1')
|
|
})
|
|
|
|
it('显示命中数量提示', async () => {
|
|
const { pinia, store } = setup()
|
|
store.createTask({ title: 'A', priority: 3 })
|
|
store.createTask({ title: 'B', priority: 1 })
|
|
const wrapper = mountBoard(pinia)
|
|
|
|
useFilterStore().togglePriority(3)
|
|
await nextTick()
|
|
|
|
expect(wrapper.text()).toContain('显示 1 / 2 个任务')
|
|
})
|
|
|
|
it('清空筛选后恢复全部卡片', async () => {
|
|
const { pinia, store } = setup()
|
|
store.createTask({ title: 'A', priority: 3 })
|
|
store.createTask({ title: 'B', priority: 1 })
|
|
const wrapper = mountBoard(pinia)
|
|
|
|
const filters = useFilterStore()
|
|
filters.togglePriority(3)
|
|
await nextTick()
|
|
filters.clear()
|
|
await nextTick()
|
|
|
|
expect(wrapper.findAll('article')).toHaveLength(2)
|
|
expect(wrapper.text()).not.toContain('显示 1 / 2')
|
|
})
|
|
|
|
it('点击「逾期」只显示逾期任务', async () => {
|
|
const { pinia, store } = setup()
|
|
store.createTask({
|
|
title: '逾期任务',
|
|
dueDate: dayjs().subtract(1, 'day').format('YYYY-MM-DD'),
|
|
})
|
|
store.createTask({ title: '未来任务', dueDate: dayjs().add(3, 'day').format('YYYY-MM-DD') })
|
|
const wrapper = mountBoard(pinia)
|
|
|
|
const overdueButton = wrapper.findAll('button').find((node) => node.text().includes('逾期'))
|
|
await overdueButton?.trigger('click')
|
|
await nextTick()
|
|
|
|
expect(wrapper.text()).toContain('逾期任务')
|
|
expect(wrapper.text()).not.toContain('未来任务')
|
|
})
|
|
|
|
it('列内渲染子集时给列组件传 sortDisabled(拖拽保护)', async () => {
|
|
const { pinia, store } = setup()
|
|
store.createTask({ title: 'A' })
|
|
const wrapper = mountBoard(pinia)
|
|
|
|
expect(wrapper.findAllComponents(BoardColumn)[0]?.props('sortDisabled')).toBe(false)
|
|
|
|
useFilterStore().togglePriority(3)
|
|
await nextTick()
|
|
expect(wrapper.findAllComponents(BoardColumn)[0]?.props('sortDisabled')).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('BoardView 归档', () => {
|
|
function archiveButton(wrapper: ReturnType<typeof mountBoard>) {
|
|
return wrapper.findAll('button').find((node) => node.text().includes('个已完成'))
|
|
}
|
|
|
|
async function createArchivable(store: ReturnType<typeof useTaskStore>) {
|
|
const task = store.createTask({ title: '很久前完成的任务', status: 'done' })
|
|
store.updateTask(task?.id ?? '', {
|
|
completedAt: dayjs().subtract(30, 'day').toISOString(),
|
|
})
|
|
return task
|
|
}
|
|
|
|
it('默认不显示归档任务', async () => {
|
|
const { pinia, store } = setup()
|
|
const task = await createArchivable(store)
|
|
store.archiveTasks([task?.id ?? ''])
|
|
|
|
const wrapper = mountBoard(pinia)
|
|
|
|
expect(wrapper.findAll('article')).toHaveLength(0)
|
|
expect(wrapper.text()).toContain('显示 0 / 1 个任务')
|
|
})
|
|
|
|
it('开启「显示归档」后归档任务重新出现', async () => {
|
|
const { pinia, store } = setup()
|
|
const task = await createArchivable(store)
|
|
store.archiveTasks([task?.id ?? ''])
|
|
|
|
const wrapper = mountBoard(pinia)
|
|
const toggle = wrapper.findAll('button').find((node) => node.text() === '显示归档')
|
|
await toggle?.trigger('click')
|
|
await nextTick()
|
|
|
|
expect(wrapper.findAll('article')).toHaveLength(1)
|
|
expect(wrapper.text()).toContain('很久前完成的任务')
|
|
})
|
|
|
|
it('归档后给出可撤销提示,撤销后任务回到看板', async () => {
|
|
const { pinia, store } = setup()
|
|
await createArchivable(store)
|
|
const wrapper = mountBoard(pinia)
|
|
|
|
await archiveButton(wrapper)?.trigger('click')
|
|
await nextTick()
|
|
|
|
expect(store.tasks[0]?.archivedAt).not.toBeNull()
|
|
expect(wrapper.findAll('article')).toHaveLength(0)
|
|
|
|
const toast = useToastStore()
|
|
expect(toast.toasts[0]?.action?.label).toBe('撤销')
|
|
|
|
toast.runAction(toast.toasts[0]?.id ?? '')
|
|
await nextTick()
|
|
|
|
expect(store.tasks[0]?.archivedAt).toBeNull()
|
|
expect(wrapper.findAll('article')).toHaveLength(1)
|
|
})
|
|
|
|
it('隐藏归档时「已完成」列禁用列内排序', async () => {
|
|
const { pinia, store } = setup()
|
|
const task = await createArchivable(store)
|
|
store.archiveTasks([task?.id ?? ''])
|
|
|
|
const wrapper = mountBoard(pinia)
|
|
// 归档任务只可能在「已完成」列
|
|
expect(wrapper.findAllComponents(BoardColumn)[2]?.props('sortDisabled')).toBe(true)
|
|
expect(wrapper.findAllComponents(BoardColumn)[0]?.props('sortDisabled')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('BoardView 快捷键', () => {
|
|
function pressKey(key: string, target: EventTarget = document): void {
|
|
target.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
|
|
}
|
|
|
|
it('按 N 聚焦待办列的快速新增输入框', async () => {
|
|
const { pinia } = setup()
|
|
const wrapper = mountBoard(pinia)
|
|
await nextTick()
|
|
|
|
pressKey('n')
|
|
expect(document.activeElement).toBe(wrapper.find('input[aria-label="添加任务"]').element)
|
|
})
|
|
|
|
it('按 / 聚焦搜索框', async () => {
|
|
const { pinia } = setup()
|
|
const wrapper = mountBoard(pinia)
|
|
await nextTick()
|
|
|
|
pressKey('/')
|
|
expect(document.activeElement).toBe(wrapper.find('input[aria-label="搜索任务"]').element)
|
|
})
|
|
|
|
it('按 ? 打开快捷键帮助', async () => {
|
|
const { pinia } = setup()
|
|
const wrapper = mountBoard(pinia)
|
|
await nextTick()
|
|
|
|
pressKey('?')
|
|
await nextTick()
|
|
|
|
expect(wrapper.text()).toContain('聚焦「待办」列的快速新增输入框')
|
|
})
|
|
|
|
it('在输入框内按 N 不触发快捷键', async () => {
|
|
const { pinia } = setup()
|
|
const wrapper = mountBoard(pinia)
|
|
await nextTick()
|
|
|
|
const search = wrapper.find('input[aria-label="搜索任务"]')
|
|
pressKey('n', search.element)
|
|
|
|
expect(document.activeElement).not.toBe(wrapper.find('input[aria-label="添加任务"]').element)
|
|
})
|
|
|
|
it('面板打开时不响应快捷键', async () => {
|
|
const { pinia } = setup()
|
|
const wrapper = mountBoard(pinia)
|
|
await nextTick()
|
|
|
|
await wrapper.find('button[aria-label="快捷键帮助"]').trigger('click')
|
|
await nextTick()
|
|
pressKey('n')
|
|
|
|
expect(document.activeElement).not.toBe(wrapper.find('input[aria-label="添加任务"]').element)
|
|
})
|
|
|
|
it('按 Esc 关闭已打开的面板', async () => {
|
|
const { pinia } = setup()
|
|
const wrapper = mountBoard(pinia)
|
|
await nextTick()
|
|
|
|
await wrapper.find('button[aria-label="快捷键帮助"]').trigger('click')
|
|
await nextTick()
|
|
expect(wrapper.text()).toContain('聚焦「待办」列的快速新增输入框')
|
|
|
|
pressKey('Escape')
|
|
await nextTick()
|
|
expect(wrapper.text()).not.toContain('聚焦「待办」列的快速新增输入框')
|
|
})
|
|
})
|