Files
dodo/frontend/src/lib/task-utils.test.ts
T
bboysoul 4973d56a64
ci / gitleaks (push) Successful in 8s
ci / docker (push) Successful in 7m17s
refactor: remove manual refresh and search controls
2026-09-19 21:15:23 +08:00

146 lines
9.3 KiB
TypeScript

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<script>alert(1)</script>')
expect(html).toContain('<h1>Plan</h1>')
expect(html).toContain('<p>First line<br>\nsecond line</p>')
expect(html).toMatch(/<li>\s*<p>parent<\/p>\s*<ul>\s*<li>child<\/li>\s*<\/ul>\s*<\/li>/)
expect(html).toContain('class="task-list-item')
expect(html).toContain('type="checkbox"')
expect(html).toContain('checked=""')
expect(html).toContain('<pre><code class="language-js">const x = 1\n</code></pre>')
expect(html).toContain('<strong>bold</strong>')
expect(html).toContain('target="_blank"')
expect(html).toContain('rel="noopener noreferrer"')
expect(html).not.toContain('<script>')
expect(html).toContain('&lt;script&gt;alert(1)&lt;/script&gt;')
})
it('inserts markdown around selections and prefixes selected lines', () => {
expect(applyMarkdownFormat('hello', 0, 5, 'bold')).toEqual({ value: '**hello**', start: 2, end: 7 })
expect(applyMarkdownFormat('one\ntwo', 0, 7, 'bullet')).toEqual({ value: '- one\n- two', start: 2, end: 11 })
expect(applyMarkdownFormat('', 0, 0, 'link')).toEqual({ value: '[链接文字](https://)', start: 1, end: 5 })
expect(applyMarkdownFormat('work', 0, 4, 'task')).toEqual({ value: '- [ ] work', start: 6, end: 10 })
})
it('defaults new tasks to today without a time', () => {
const due = defaultTaskDueAt(new Date(2026, 8, 8, 21, 30))
expect(due).toBe('2026-09-08')
})
it('formats API dates in local wall-clock time', () => {
const original = process.env.TZ
process.env.TZ = 'Asia/Shanghai'
expect(toDateTimeLocal('2026-09-05T12:30:00Z')).toBe('2026-09-05T20:30')
process.env.TZ = original
expect(toDateTimeLocal(null)).toBe('')
})
it('parses date-only and timed task deadlines without inventing a time', () => {
const original = process.env.TZ
process.env.TZ = 'Asia/Shanghai'
expect(parseTaskDueDraft('2026-09-05T15:59:00Z', false)).toEqual({ date: '2026-09-05', hasTime: false, time: '12:00' })
expect(parseTaskDueDraft('2026-09-05T12:30:00Z', true)).toEqual({ date: '2026-09-05', hasTime: true, time: '20:30' })
expect(parseTaskDueDraft(null, true)).toEqual({ date: '', hasTime: false, time: '12:00' })
process.env.TZ = original
})
it('builds date-only, timed, toggled and cleared task deadlines in local time', () => {
const original = process.env.TZ
process.env.TZ = 'Asia/Shanghai'
expect(buildTaskDueDraft({ date: '2026-09-05', hasTime: false, time: '08:15' })).toEqual({ due_at: '2026-09-05T15:59:00.000Z', due_has_time: false })
expect(buildTaskDueDraft({ date: '2026-09-05', hasTime: true, time: '08:15' })).toEqual({ due_at: '2026-09-05T00:15:00.000Z', due_has_time: true })
expect(buildTaskDueDraft({ date: '2026-09-05', hasTime: true, time: '' })).toEqual({ due_at: '2026-09-05T15:59:00.000Z', due_has_time: false })
expect(buildTaskDueDraft({ date: '', hasTime: true, time: '08:15' })).toEqual({ due_at: null, due_has_time: false })
process.env.TZ = original
})
})