- 在任务创建和编辑界面中新增起始日期字段,默认值为当天。 - 更新任务卡片展示逻辑,仅在起始日期不是当天时显示相关信息。 - 修改相关测试用例以覆盖起始日期的功能,包括批量创建和编辑任务的场景。 - 数据库模式版本更新至4,以支持起始日期字段的存储与迁移。
380 lines
12 KiB
TypeScript
380 lines
12 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
|
|
import {
|
|
DataValidationError,
|
|
SCHEMA_VERSION,
|
|
coerceTask,
|
|
coerceTaskList,
|
|
isValidDateString,
|
|
parseImportJson,
|
|
parsePersistedState,
|
|
serializeState,
|
|
validateImportPayload,
|
|
} from './schema'
|
|
|
|
function validTask(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
return {
|
|
id: 'task-1',
|
|
title: '写方案',
|
|
description: '细节',
|
|
status: 'todo',
|
|
priority: 2,
|
|
tags: ['设计'],
|
|
dueDate: '2026-10-20',
|
|
startDate: null,
|
|
sortOrder: 1024,
|
|
createdAt: '2026-10-18T00:00:00.000Z',
|
|
updatedAt: '2026-10-18T00:00:00.000Z',
|
|
completedAt: null,
|
|
archivedAt: null,
|
|
deletedAt: null,
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
describe('isValidDateString', () => {
|
|
it('接受合法日期', () => {
|
|
expect(isValidDateString('2026-10-18')).toBe(true)
|
|
expect(isValidDateString('2024-02-29')).toBe(true)
|
|
})
|
|
|
|
it('拒绝格式不对的值', () => {
|
|
expect(isValidDateString('2026-1-1')).toBe(false)
|
|
expect(isValidDateString('2026/10/18')).toBe(false)
|
|
expect(isValidDateString('')).toBe(false)
|
|
expect(isValidDateString(20261018)).toBe(false)
|
|
expect(isValidDateString(null)).toBe(false)
|
|
})
|
|
|
|
it('拒绝「格式正确但日期不存在」的值', () => {
|
|
expect(isValidDateString('2026-02-31')).toBe(false)
|
|
expect(isValidDateString('2026-13-01')).toBe(false)
|
|
expect(isValidDateString('2025-02-29')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('coerceTask', () => {
|
|
it('接受完整合法任务', () => {
|
|
expect(coerceTask(validTask())).toEqual(validTask())
|
|
})
|
|
|
|
it('缺少 id 或 title 时返回 null', () => {
|
|
expect(coerceTask(validTask({ id: undefined }))).toBeNull()
|
|
expect(coerceTask(validTask({ title: ' ' }))).toBeNull()
|
|
expect(coerceTask(validTask({ title: 123 }))).toBeNull()
|
|
})
|
|
|
|
it('status 或 priority 非法时返回 null', () => {
|
|
expect(coerceTask(validTask({ status: 'archived' }))).toBeNull()
|
|
expect(coerceTask(validTask({ priority: 9 }))).toBeNull()
|
|
expect(coerceTask(validTask({ priority: '2' }))).toBeNull()
|
|
})
|
|
|
|
it('非对象一律返回 null', () => {
|
|
expect(coerceTask(null)).toBeNull()
|
|
expect(coerceTask('task')).toBeNull()
|
|
expect(coerceTask([])).toBeNull()
|
|
})
|
|
|
|
it('可选项缺失时补默认值', () => {
|
|
const result = coerceTask({
|
|
id: 'a',
|
|
title: '只有必填项',
|
|
status: 'doing',
|
|
priority: 1,
|
|
})
|
|
expect(result).not.toBeNull()
|
|
expect(result?.description).toBe('')
|
|
expect(result?.tags).toEqual([])
|
|
expect(result?.dueDate).toBeNull()
|
|
expect(result?.sortOrder).toBe(0)
|
|
expect(result?.completedAt).toBeNull()
|
|
expect(result?.createdAt).toBeTruthy()
|
|
})
|
|
|
|
it('非法 dueDate 收敛为 null,非法 sortOrder 收敛为 0', () => {
|
|
const result = coerceTask(validTask({ dueDate: '2026-02-31', sortOrder: 'abc' }))
|
|
expect(result?.dueDate).toBeNull()
|
|
expect(result?.sortOrder).toBe(0)
|
|
})
|
|
|
|
it('合法 startDate 保留,非法 startDate 收敛为 null', () => {
|
|
expect(coerceTask(validTask({ startDate: '2026-10-01' }))?.startDate).toBe('2026-10-01')
|
|
expect(coerceTask(validTask({ startDate: '2026-02-31' }))?.startDate).toBeNull()
|
|
expect(coerceTask(validTask({ startDate: undefined }))?.startDate).toBeNull()
|
|
})
|
|
|
|
it('tags 过滤掉非字符串与空白项', () => {
|
|
const result = coerceTask(validTask({ tags: ['ok', 1, null, ' ', 'ok2'] }))
|
|
expect(result?.tags).toEqual(['ok', 'ok2'])
|
|
})
|
|
|
|
it('非法 archivedAt 收敛为 null', () => {
|
|
expect(coerceTask(validTask({ archivedAt: 123 }))?.archivedAt).toBeNull()
|
|
expect(coerceTask(validTask({ archivedAt: null }))?.archivedAt).toBeNull()
|
|
})
|
|
|
|
it('非法 deletedAt 收敛为 null', () => {
|
|
expect(coerceTask(validTask({ deletedAt: 123 }))?.deletedAt).toBeNull()
|
|
expect(coerceTask(validTask({ deletedAt: null }))?.deletedAt).toBeNull()
|
|
expect(coerceTask(validTask({ deletedAt: undefined }))?.deletedAt).toBeNull()
|
|
})
|
|
|
|
it('保留合法的 deletedAt', () => {
|
|
expect(coerceTask(validTask({ deletedAt: '2026-10-19T00:00:00.000Z' }))?.deletedAt).toBe(
|
|
'2026-10-19T00:00:00.000Z',
|
|
)
|
|
})
|
|
|
|
it('保留合法的 archivedAt', () => {
|
|
expect(coerceTask(validTask({ archivedAt: '2026-10-01T00:00:00.000Z' }))?.archivedAt).toBe(
|
|
'2026-10-01T00:00:00.000Z',
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('coerceTaskList', () => {
|
|
it('跳过损坏条目但保留合法条目', () => {
|
|
const list = coerceTaskList([validTask(), { id: 'broken' }, validTask({ id: 'task-2' })])
|
|
expect(list.map((task) => task.id)).toEqual(['task-1', 'task-2'])
|
|
})
|
|
|
|
it('非数组返回空数组', () => {
|
|
expect(coerceTaskList(null)).toEqual([])
|
|
expect(coerceTaskList({})).toEqual([])
|
|
})
|
|
|
|
it('重复 id 只保留先出现的那条', () => {
|
|
const list = coerceTaskList([
|
|
validTask({ id: 'dup', title: '第一条' }),
|
|
validTask({ id: 'dup', title: '第二条' }),
|
|
validTask({ id: 'other' }),
|
|
])
|
|
|
|
expect(list.map((task) => task.id)).toEqual(['dup', 'other'])
|
|
expect(list[0]?.title).toBe('第一条')
|
|
})
|
|
})
|
|
|
|
describe('parsePersistedState', () => {
|
|
it('空值返回空看板', () => {
|
|
expect(parsePersistedState(null)).toEqual({ schemaVersion: SCHEMA_VERSION, tasks: [] })
|
|
expect(parsePersistedState(' ')).toEqual({ schemaVersion: SCHEMA_VERSION, tasks: [] })
|
|
})
|
|
|
|
it('非 JSON 抛 DataValidationError', () => {
|
|
expect(() => parsePersistedState('{oops')).toThrow(DataValidationError)
|
|
})
|
|
|
|
it('非对象抛 DataValidationError', () => {
|
|
expect(() => parsePersistedState('[1,2,3]')).toThrow(DataValidationError)
|
|
})
|
|
|
|
it('缺少 schemaVersion 的旧数据迁移为当前版本', () => {
|
|
const legacy = JSON.stringify({ tasks: [validTask()] })
|
|
const state = parsePersistedState(legacy)
|
|
expect(state.schemaVersion).toBe(SCHEMA_VERSION)
|
|
expect(state.tasks).toHaveLength(1)
|
|
})
|
|
|
|
it('版本高于当前支持时抛错(不静默降级)', () => {
|
|
const future = JSON.stringify({ schemaVersion: SCHEMA_VERSION + 1, tasks: [] })
|
|
expect(() => parsePersistedState(future)).toThrow(/高于当前支持/)
|
|
})
|
|
|
|
it('损坏条目被跳过,其余数据仍可用', () => {
|
|
const mixed = JSON.stringify({
|
|
schemaVersion: SCHEMA_VERSION,
|
|
tasks: [validTask(), { id: 'x', title: '' }],
|
|
})
|
|
expect(parsePersistedState(mixed).tasks).toHaveLength(1)
|
|
})
|
|
})
|
|
|
|
describe('版本迁移', () => {
|
|
it('v1 数据迁移到 v2 时补上 archivedAt,其余字段不变', () => {
|
|
const v1 = JSON.stringify({
|
|
schemaVersion: 1,
|
|
tasks: [
|
|
{
|
|
id: 'legacy',
|
|
title: '旧任务',
|
|
status: 'done',
|
|
priority: 3,
|
|
sortOrder: 1024,
|
|
createdAt: '2026-01-01T00:00:00.000Z',
|
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
|
completedAt: '2026-01-02T00:00:00.000Z',
|
|
},
|
|
],
|
|
})
|
|
|
|
const state = parsePersistedState(v1)
|
|
|
|
expect(state.schemaVersion).toBe(SCHEMA_VERSION)
|
|
expect(state.tasks[0]?.archivedAt).toBeNull()
|
|
expect(state.tasks[0]?.title).toBe('旧任务')
|
|
expect(state.tasks[0]?.priority).toBe(3)
|
|
expect(state.tasks[0]?.completedAt).toBe('2026-01-02T00:00:00.000Z')
|
|
})
|
|
|
|
it('无版本号的最早期数据能一路迁移到当前版本', () => {
|
|
const legacy = JSON.stringify({
|
|
tasks: [{ id: 'a', title: '最早的任务', status: 'todo', priority: 2 }],
|
|
})
|
|
|
|
const state = parsePersistedState(legacy)
|
|
|
|
expect(state.schemaVersion).toBe(SCHEMA_VERSION)
|
|
expect(state.tasks[0]?.archivedAt).toBeNull()
|
|
})
|
|
|
|
it('归档状态在序列化往返后保留', () => {
|
|
const tasks = coerceTaskList([validTask({ archivedAt: '2026-10-01T00:00:00.000Z' })])
|
|
const roundTrip = parsePersistedState(serializeState(tasks))
|
|
expect(roundTrip.tasks[0]?.archivedAt).toBe('2026-10-01T00:00:00.000Z')
|
|
})
|
|
|
|
it('多版本迁移对多条任务都生效', () => {
|
|
const v1 = JSON.stringify({
|
|
schemaVersion: 1,
|
|
tasks: [
|
|
{ id: 'a', title: 'A', status: 'todo', priority: 1 },
|
|
{ id: 'b', title: 'B', status: 'done', priority: 2 },
|
|
],
|
|
})
|
|
const state = parsePersistedState(v1)
|
|
expect(state.tasks.map((task) => task.archivedAt)).toEqual([null, null])
|
|
})
|
|
|
|
it('v2 数据迁移到 v3 时补上 deletedAt,其余字段不变', () => {
|
|
const v2 = JSON.stringify({
|
|
schemaVersion: 2,
|
|
tasks: [
|
|
{
|
|
id: 'legacy',
|
|
title: '归档任务',
|
|
status: 'done',
|
|
priority: 2,
|
|
sortOrder: 1024,
|
|
createdAt: '2026-01-01T00:00:00.000Z',
|
|
updatedAt: '2026-01-02T00:00:00.000Z',
|
|
completedAt: '2026-01-02T00:00:00.000Z',
|
|
archivedAt: '2026-01-03T00:00:00.000Z',
|
|
},
|
|
],
|
|
})
|
|
|
|
const state = parsePersistedState(v2)
|
|
|
|
expect(state.schemaVersion).toBe(SCHEMA_VERSION)
|
|
expect(state.tasks[0]?.deletedAt).toBeNull()
|
|
expect(state.tasks[0]?.archivedAt).toBe('2026-01-03T00:00:00.000Z')
|
|
})
|
|
|
|
it('v3 数据迁移到 v4 时补上 startDate,其余字段不变', () => {
|
|
const v3 = JSON.stringify({
|
|
schemaVersion: 3,
|
|
tasks: [
|
|
{
|
|
id: 'legacy',
|
|
title: '旧任务',
|
|
status: 'todo',
|
|
priority: 2,
|
|
sortOrder: 1024,
|
|
createdAt: '2026-01-01T00:00:00.000Z',
|
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
|
completedAt: null,
|
|
archivedAt: null,
|
|
deletedAt: null,
|
|
},
|
|
],
|
|
})
|
|
|
|
const state = parsePersistedState(v3)
|
|
|
|
expect(state.schemaVersion).toBe(SCHEMA_VERSION)
|
|
expect(state.tasks[0]?.startDate).toBeNull()
|
|
expect(state.tasks[0]?.deletedAt).toBeNull()
|
|
expect(state.tasks[0]?.title).toBe('旧任务')
|
|
})
|
|
|
|
it('v0/v1/v2/v3 都能一路迁移到当前版本', () => {
|
|
const builds = [
|
|
() => ({ tasks: [{ id: 'a', title: 'A', status: 'todo', priority: 2 }] }),
|
|
() => ({ schemaVersion: 1, tasks: [{ id: 'a', title: 'A', status: 'todo', priority: 2 }] }),
|
|
() => ({ schemaVersion: 2, tasks: [{ id: 'a', title: 'A', status: 'todo', priority: 2 }] }),
|
|
() => ({ schemaVersion: 3, tasks: [{ id: 'a', title: 'A', status: 'todo', priority: 2 }] }),
|
|
]
|
|
|
|
for (const build of builds) {
|
|
const state = parsePersistedState(JSON.stringify(build()))
|
|
expect(state.schemaVersion).toBe(SCHEMA_VERSION)
|
|
expect(state.tasks[0]?.deletedAt).toBeNull()
|
|
expect(state.tasks[0]?.startDate).toBeNull()
|
|
}
|
|
})
|
|
|
|
it('删除状态在序列化往返后保留', () => {
|
|
const tasks = coerceTaskList([validTask({ deletedAt: '2026-10-19T00:00:00.000Z' })])
|
|
const roundTrip = parsePersistedState(serializeState(tasks))
|
|
expect(roundTrip.tasks[0]?.deletedAt).toBe('2026-10-19T00:00:00.000Z')
|
|
})
|
|
})
|
|
|
|
describe('序列化往返', () => {
|
|
it('serializeState 与 parsePersistedState 互逆', () => {
|
|
const tasks = coerceTaskList([validTask(), validTask({ id: 'task-2', status: 'done' })])
|
|
const roundTrip = parsePersistedState(serializeState(tasks))
|
|
expect(roundTrip.tasks).toEqual(tasks)
|
|
expect(roundTrip.schemaVersion).toBe(SCHEMA_VERSION)
|
|
})
|
|
|
|
it('pretty 模式仍可解析', () => {
|
|
const tasks = coerceTaskList([validTask()])
|
|
const pretty = serializeState(tasks, true)
|
|
expect(pretty).toContain('\n')
|
|
expect(parsePersistedState(pretty).tasks).toEqual(tasks)
|
|
})
|
|
})
|
|
|
|
describe('导入校验(严格)', () => {
|
|
it('任何一条任务不合法即整体拒绝', () => {
|
|
expect(() => validateImportPayload({ tasks: [validTask(), { id: 'broken' }] })).toThrow(
|
|
/第 2 条任务格式不合法/,
|
|
)
|
|
})
|
|
|
|
it('id 重复时整体拒绝,并指出重复的两条', () => {
|
|
const duplicated = [
|
|
validTask({ id: 'same' }),
|
|
validTask({ id: 'other' }),
|
|
validTask({ id: 'same' }),
|
|
]
|
|
|
|
expect(() => validateImportPayload({ tasks: duplicated })).toThrow(
|
|
/第 3 条与第 1 条任务的 id 相同(same)/,
|
|
)
|
|
})
|
|
|
|
it('缺少 tasks 数组时抛错', () => {
|
|
expect(() => validateImportPayload({ schemaVersion: 1 })).toThrow(/缺少 tasks 数组/)
|
|
})
|
|
|
|
it('非对象时抛错', () => {
|
|
expect(() => validateImportPayload([])).toThrow(/必须是一个 JSON 对象/)
|
|
})
|
|
|
|
it('版本过高时拒绝导入', () => {
|
|
expect(() => validateImportPayload({ schemaVersion: 99, tasks: [] })).toThrow(/高于当前支持/)
|
|
})
|
|
|
|
it('允许省略 schemaVersion', () => {
|
|
expect(validateImportPayload({ tasks: [validTask()] })).toHaveLength(1)
|
|
})
|
|
|
|
it('parseImportJson 对非法 JSON 给出明确错误', () => {
|
|
expect(() => parseImportJson('not json')).toThrow(/不是合法的 JSON/)
|
|
})
|
|
})
|