import { describe, expect, it } from 'vitest' import { DataValidationError, SCHEMA_VERSION, coerceTask, coerceTaskList, isValidDateString, parseImportJson, parsePersistedState, serializeState, validateImportPayload, } from './schema' function validTask(overrides: Record = {}): Record { 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/) }) })