从零实现一个 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)。
77 lines
2.4 KiB
TypeScript
77 lines
2.4 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
|
|
import { resolveDrop, shouldAllowMove } from './drag'
|
|
|
|
function makeList(columnId?: string): HTMLElement {
|
|
const element = document.createElement('div')
|
|
if (columnId !== undefined) element.dataset.columnId = columnId
|
|
return element
|
|
}
|
|
|
|
function makeItem(taskId?: string): HTMLElement {
|
|
const element = document.createElement('article')
|
|
if (taskId !== undefined) element.dataset.taskId = taskId
|
|
return element
|
|
}
|
|
|
|
describe('resolveDrop', () => {
|
|
it('解析出任务 id、目标列与下标', () => {
|
|
const drop = resolveDrop({
|
|
item: makeItem('task-1'),
|
|
to: makeList('doing'),
|
|
newIndex: 2,
|
|
})
|
|
expect(drop).toEqual({ taskId: 'task-1', toColumn: 'doing', toIndex: 2 })
|
|
})
|
|
|
|
it('下标为 0 是合法值', () => {
|
|
expect(resolveDrop({ item: makeItem('t'), to: makeList('todo'), newIndex: 0 })).toEqual({
|
|
taskId: 't',
|
|
toColumn: 'todo',
|
|
toIndex: 0,
|
|
})
|
|
})
|
|
|
|
it('缺少 taskId 时返回 null', () => {
|
|
expect(resolveDrop({ item: makeItem(), to: makeList('todo'), newIndex: 0 })).toBeNull()
|
|
})
|
|
|
|
it('目标列 id 非法时返回 null', () => {
|
|
expect(
|
|
resolveDrop({ item: makeItem('t'), to: makeList('archived'), newIndex: 0 }),
|
|
).toBeNull()
|
|
expect(resolveDrop({ item: makeItem('t'), to: makeList(), newIndex: 0 })).toBeNull()
|
|
})
|
|
|
|
it('下标缺失或非法时返回 null', () => {
|
|
const item = makeItem('t')
|
|
const to = makeList('todo')
|
|
expect(resolveDrop({ item, to })).toBeNull()
|
|
expect(resolveDrop({ item, to, newIndex: -1 })).toBeNull()
|
|
expect(resolveDrop({ item, to, newIndex: 1.5 })).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('shouldAllowMove', () => {
|
|
it('列内为全量渲染时一律允许', () => {
|
|
const a = makeList('todo')
|
|
const b = makeList('doing')
|
|
expect(shouldAllowMove(a, a, false)).toBe(true)
|
|
expect(shouldAllowMove(a, b, false)).toBe(true)
|
|
})
|
|
|
|
it('列内为子集时禁止列内排序', () => {
|
|
const column = makeList('todo')
|
|
expect(shouldAllowMove(column, column, true)).toBe(false)
|
|
})
|
|
|
|
it('列内为子集时仍允许跨列移动', () => {
|
|
expect(shouldAllowMove(makeList('todo'), makeList('doing'), true)).toBe(true)
|
|
})
|
|
|
|
it('子集状态下元素信息缺失时保守拒绝', () => {
|
|
expect(shouldAllowMove(null, makeList('todo'), true)).toBe(false)
|
|
expect(shouldAllowMove(makeList('todo'), undefined, true)).toBe(false)
|
|
})
|
|
})
|