Files
Kanban/src/data/schema.spec.ts
T
chenshoulin 6d58227614
CI / verify (push) Successful in 1m57s
CI / 部署到服务器 (push) Successful in 29s
feat: 添加起始日期功能并更新相关组件与测试用例
- 在任务创建和编辑界面中新增起始日期字段,默认值为当天。
- 更新任务卡片展示逻辑,仅在起始日期不是当天时显示相关信息。
- 修改相关测试用例以覆盖起始日期的功能,包括批量创建和编辑任务的场景。
- 数据库模式版本更新至4,以支持起始日期字段的存储与迁移。
2026-11-02 00:17:14 +08:00

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