import { describe, expect, it } from 'vitest' import { applyMarkdownFormat, buildTaskDueDraft, buildTaskRecurrencePayload, buildTaskRrule, classifyTaskForToday, defaultTaskDueAt, groupTaskTree, isSameTaskSortTier, mergeReorderedSubset, moveItemWithinScope, parseTaskDueDraft, parseTaskRecurrence, parseTaskRrule, renderMarkdown, toDateTimeLocal } from './task-utils' type TaskFixture = { id: string title: string description?: string parent_id?: string | null completed?: boolean list_name?: string subtasks?: TaskFixture[] } const tasks: TaskFixture[] = [ { id: '1', title: 'Write release notes', description: 'mention **API**', parent_id: null, completed: false }, { id: '2', title: 'Check links', description: '', parent_id: '1', completed: false }, { id: '3', title: 'Buy milk', description: '', parent_id: null, completed: true }, ] describe('task utilities', () => { it('keeps a one-level subtask tree without duplicating children', () => { expect(groupTaskTree(tasks)).toEqual([{ task: tasks[0], subtasks: [tasks[1]] }, { task: tasks[2], subtasks: [] }]) }) it('preserves subtasks already nested by the task API', () => { const child: TaskFixture = { id: 'nested-child', title: 'Nested child', parent_id: 'nested-parent' } const parent: TaskFixture = { id: 'nested-parent', title: 'Nested parent', parent_id: null, subtasks: [child] } expect(groupTaskTree([parent])).toEqual([{ task: parent, subtasks: [child] }]) }) it('classifies due edits for Today membership', () => { const start = new Date('2026-09-10T00:00:00+08:00') const end = new Date('2026-09-11T00:00:00+08:00') expect(classifyTaskForToday({ completed: false, due_at: '2026-09-09T12:00:00+08:00' }, start, end)).toBe('overdue') expect(classifyTaskForToday({ completed: false, due_at: '2026-09-10T12:00:00+08:00' }, start, end)).toBe('today') expect(classifyTaskForToday({ completed: false, due_at: '2026-09-11T12:00:00+08:00' }, start, end)).toBe('outside') expect(classifyTaskForToday({ completed: true, due_at: '2026-09-09T12:00:00+08:00' }, start, end)).toBe('outside') }) it('only allows manual movement within the same completion and deadline tier', () => { const noDueOpen = { completed: false, due_at: null } expect(isSameTaskSortTier(noDueOpen, { completed: false, due_at: null })).toBe(true) expect(isSameTaskSortTier({ completed: false, due_at: '2026-09-10T08:00:00Z' }, { completed: false, due_at: '2026-09-10T08:00:00Z' })).toBe(true) expect(isSameTaskSortTier({ completed: false, due_at: '2026-09-10T08:00:00Z' }, { completed: false, due_at: '2026-09-10T16:00:00+08:00' })).toBe(true) expect(isSameTaskSortTier(noDueOpen, { completed: false, due_at: '2026-09-10T08:00:00Z' })).toBe(false) expect(isSameTaskSortTier(noDueOpen, { completed: true, due_at: null })).toBe(false) expect(isSameTaskSortTier({ completed: false, due_at: '2026-09-10T08:00:00Z' }, { completed: false, due_at: '2026-09-11T08:00:00Z' })).toBe(false) }) it('moves an item before or after another item without changing other scopes', () => { const rows = [ { id: 'a', parent_id: null }, { id: 'a1', parent_id: 'a' }, { id: 'b', parent_id: null }, { id: 'c', parent_id: null }, ] expect(moveItemWithinScope(rows, 'c', 'a', 'before').map((row) => row.id)).toEqual(['c', 'a1', 'a', 'b']) expect(moveItemWithinScope(rows, 'a', 'c', 'after').map((row) => row.id)).toEqual(['b', 'a1', 'c', 'a']) expect(moveItemWithinScope(rows, 'a1', 'b', 'before')).toEqual(rows) }) it('merges a reordered visible subset without moving hidden slots', () => { const rows = [{ id: 'a' }, { id: 'hidden-x' }, { id: 'hidden-y' }, { id: 'b' }] expect(mergeReorderedSubset(rows, [rows[3], rows[0]]).map((row) => row.id)).toEqual([ 'b', 'hidden-x', 'hidden-y', 'a', ]) }) it('builds and parses custom repeat rules like TickTick', () => { expect(buildTaskRrule({ frequency: 'weekly', interval: 2, weekdays: ['MO', 'WE', 'FR'], endMode: 'never' })).toBe('FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR') expect(buildTaskRrule({ frequency: 'monthly', interval: 1, monthDays: [1, 15, 31], endMode: 'count', count: 10 })).toBe('FREQ=MONTHLY;BYMONTHDAY=1,15,31;COUNT=10') expect(buildTaskRrule({ frequency: 'yearly', interval: 1, weekdays: [], monthDays: [], endMode: 'date', count: 10, until: '2027-12-31' })).toBe('FREQ=YEARLY;UNTIL=2027-12-31T23:59:59') expect(parseTaskRrule('FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE;COUNT=8')).toEqual({ frequency: 'weekly', interval: 2, weekdays: ['MO', 'WE'], monthDays: [], endMode: 'count', count: 8, until: '' }) expect(() => buildTaskRrule({ frequency: 'weekly', interval: 2, weekdays: [], monthDays: [], endMode: 'never', count: 10, until: '' })).toThrow('至少选择一个重复日期') expect(() => buildTaskRrule({ frequency: 'daily', interval: 0, weekdays: [], monthDays: [], endMode: 'never', count: 10, until: '' })).toThrow('重复间隔至少为 1') }) it('builds a distinct completion-trigger payload and parses it without RRULE', () => { expect(buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '7' })).toEqual({ trigger_mode: 'after_completion', after_completion_days: 7 }) expect(parseTaskRecurrence({ rrule: null, trigger_mode: 'after_completion', after_completion_days: 14 })).toEqual({ option: 'after_completion', afterCompletionDays: 14 }) expect(() => buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '1.5' })).toThrow('请输入 1 到 3650 的整数天数') expect(() => buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '0' })).toThrow('请输入 1 到 3650 的整数天数') expect(() => buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '3651' })).toThrow('请输入 1 到 3650 的整数天数') }) it('builds scheduled recurrence payloads separately from completion triggers', () => { expect(buildTaskRecurrencePayload('daily', { afterCompletionDays: '1' })).toEqual({ trigger_mode: 'scheduled', rrule: 'FREQ=DAILY' }) expect(buildTaskRecurrencePayload('none', { afterCompletionDays: '1' })).toEqual({}) }) it('renders safe standard markdown with paragraphs, nesting, tasks, and fenced code', () => { const html = renderMarkdown('# Plan\n\nFirst line\nsecond line\n\n- parent\n - child\n\n- [x] done\n\n```js\nconst x = 1\n```\n\n**bold** [link](https://example.com)\n') expect(html).toContain('

Plan

') expect(html).toContain('

First line
\nsecond line

') expect(html).toMatch(/
  • \s*

    parent<\/p>\s*