Files
dodo/frontend/src/lib/mvp-utils.test.ts
T
bboysoul e857506bf6
ci / gitleaks (push) Successful in 53s
ci / docker (push) Successful in 4m59s
feat: show habit history in details
2026-09-15 16:32:53 +08:00

322 lines
18 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { archivePanelFlags, calendarModeLabel, changedHabitFields, clampFabPosition, countdownDayText, countdownKindLabel, dateKey, dayBefore, defaultView, formatArchivedAt, formatAuditAction, formatAuditEntity, formatHabitApiError, formatHabitHistoryNumber, formatLocalShortDateTime, formatUserAgent, habitActionState, habitButtonNotice, habitButtonValue, habitHistoryWindow, habitWeek, invalidateHabitGridCache, isFabDrag, isHabitComplete, isHabitScheduledToday, isTaskView, mergeHabitHistory, nextHabitSwipeValue, nextTotalAfterLocalTaskAdd, nextTotalAfterLocalTaskRemoval, snapFabPosition, normalizeRequiredName, numericHabitInputValue, performHabitRestore, performTrashMutation, previousHabitSwipeValue, quickTaskFields, readCountdownCache, readHabitGridCache, readStoredBoolean, readStoredNavigation, shouldToggleRowSwipe, validateHabitForm, writeCountdownCache, writeHabitGridCache, writeStoredBoolean, writeStoredNavigation } from './mvp-utils'
describe('MVP view utilities', () => {
it('formats a local date as YYYY-MM-DD', () => {
expect(dateKey(new Date(2026, 8, 5))).toBe('2026-09-05')
})
it('returns the monday-to-sunday week containing the requested day', () => {
expect(habitWeek(new Date(2026, 8, 5)).map(dateKey)).toEqual([
'2026-08-31', '2026-09-01', '2026-09-02', '2026-09-03', '2026-09-04', '2026-09-05', '2026-09-06',
])
})
it('identifies only task-backed views as task data loaders', () => {
expect(['tasks', 'today', 'upcoming'].filter(isTaskView)).toEqual(['tasks', 'today', 'upcoming'])
expect(['habits', 'settings', 'trash'].filter(isTaskView)).toEqual([])
})
it('keeps Trash totals aligned after restoring or purging visible rows', () => {
expect(nextTotalAfterLocalTaskRemoval(2, 1)).toBe(1)
expect(nextTotalAfterLocalTaskRemoval(0, 1)).toBe(0)
expect(nextTotalAfterLocalTaskRemoval(Number.NaN, 1)).toBe(0)
})
it('does not reconcile Trash state when the mutation fails', async () => {
let reconciled = 0
const result = await performTrashMutation(
async () => { throw new Error('mutation failed') },
() => { reconciled += 1 },
async () => true,
)
expect(result).toEqual({ mutated: false, refreshed: false, error: expect.any(Error) })
expect(reconciled).toBe(0)
})
it('keeps confirmed Trash reconciliation when the refresh fails', async () => {
let reconciled = 0
const result = await performTrashMutation(
async () => undefined,
() => { reconciled += 1 },
async () => false,
)
expect(result).toEqual({ mutated: true, refreshed: false })
expect(reconciled).toBe(1)
})
it('reports a fully reconciled Trash mutation after refresh succeeds', async () => {
let reconciled = 0
const result = await performTrashMutation(
async () => undefined,
() => { reconciled += 1 },
async () => true,
)
expect(result).toEqual({ mutated: true, refreshed: true })
expect(reconciled).toBe(1)
})
it('restores the last page and selected task list after refresh', () => {
const storage = new Map<string, string>()
const fakeStorage = {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
}
expect(readStoredNavigation(fakeStorage, 'dodo.navigation')).toEqual({ view: 'today', listId: '' })
writeStoredNavigation(fakeStorage, 'dodo.navigation', 'tasks', 'list-2')
expect(readStoredNavigation(fakeStorage, 'dodo.navigation')).toEqual({ view: 'tasks', listId: 'list-2' })
writeStoredNavigation(fakeStorage, 'dodo.navigation', 'memos', 'list-2')
expect(readStoredNavigation(fakeStorage, 'dodo.navigation')).toEqual({ view: 'memos', listId: 'list-2' })
storage.set('dodo.navigation', JSON.stringify({ view: 'invalid', listId: 'list-2' }))
expect(readStoredNavigation(fakeStorage, 'dodo.navigation')).toEqual({ view: 'today', listId: '' })
})
it('builds non-overlapping 90-day habit history windows', () => {
expect(habitHistoryWindow('2026-09-15')).toEqual({ from: '2026-06-18', to: '2026-09-15' })
expect(dayBefore('2026-06-18')).toBe('2026-06-17')
expect(habitHistoryWindow('2026-01-01', 2)).toEqual({ from: '2025-12-31', to: '2026-01-01' })
})
it('merges habit history by day, coerces API values, and keeps newest first', () => {
expect(mergeHabitHistory(
[{ day: '2026-09-14', value: 1 }, { day: '2026-09-13', value: 2 }],
[{ day: '2026-09-15', value: 3 }, { day: '2026-09-14', value: 4 }],
)).toEqual([
{ day: '2026-09-15', value: 3 },
{ day: '2026-09-14', value: 4 },
{ day: '2026-09-13', value: 2 },
])
expect(formatHabitHistoryNumber(8)).toBe('8')
expect(formatHabitHistoryNumber(1.25)).toBe('1.25')
})
it('only completes numeric habits when progress reaches the target', () => {
expect(isHabitComplete('numeric', 3, 5)).toBe(false)
expect(isHabitComplete('numeric', 5, 5)).toBe(true)
expect(isHabitComplete('boolean', 1, 5)).toBe(true)
})
it('does not invent a numeric value when the input is empty', () => {
expect(numericHabitInputValue(undefined)).toBeNull()
expect(numericHabitInputValue('')).toBeNull()
expect(numericHabitInputValue(4)).toBe(4)
})
it('uses today as the default landing view', () => {
expect(defaultView()).toBe('today')
})
it('quick-adds Today tasks to Inbox at noon in Asia/Shanghai', () => {
const now = new Date('2026-09-05T16:30:00.000Z')
expect(quickTaskFields('today', 'selected-list', 'inbox-list', now)).toEqual({
list_id: 'inbox-list',
due_at: '2026-09-06T12:00:00+08:00',
})
})
it('keeps quick-add tasks in the selected list outside Today', () => {
expect(quickTaskFields('tasks', 'selected-list', 'inbox-list', new Date('2026-09-05T16:30:00.000Z'))).toEqual({
list_id: 'selected-list',
})
})
it('only includes scheduled, unpaused habits today regardless of kind or completion', () => {
const day = '2026-09-06'
expect(isHabitScheduledToday({ kind: 'boolean', cells: [{ day, scheduled: true, paused: false, value: true }] }, day)).toBe(true)
expect(isHabitScheduledToday({ kind: 'numeric', cells: [{ day, scheduled: true, paused: false, value: 5 }] }, day)).toBe(true)
expect(isHabitScheduledToday({ kind: 'boolean', cells: [{ day, scheduled: false, paused: false, value: true }] }, day)).toBe(false)
expect(isHabitScheduledToday({ kind: 'numeric', cells: [{ day, scheduled: true, paused: true, value: 0 }] }, day)).toBe(false)
expect(isHabitScheduledToday({ kind: 'boolean', cells: [] }, day)).toBe(false)
})
it('increments count habits one swipe at a time and caps at target', () => {
expect(nextHabitSwipeValue('numeric', 2, 5)).toBe(3)
expect(nextHabitSwipeValue('numeric', 5, 5)).toBe(5)
expect(previousHabitSwipeValue('numeric', 3)).toBe(2)
expect(previousHabitSwipeValue('numeric', 0)).toBe(0)
})
it('resets a completed numeric habit when its round button is clicked again', () => {
expect(habitButtonValue('numeric', 2, 3)).toBe(3)
expect(habitButtonValue('numeric', 3, 3)).toBe(0)
expect(habitButtonValue('boolean', 0, 1)).toBe(1)
expect(habitButtonValue('boolean', 1, 1)).toBe(0)
})
it('uses reset wording when a completed numeric habit button returns to zero', () => {
expect(habitButtonNotice('numeric', 3, 0)).toBe('今日进度已重置')
expect(habitButtonNotice('numeric', 2, 3)).toBe('已记录一次 🎉')
expect(habitButtonNotice('numeric', 2, 1)).toBe('已减少一次')
expect(habitButtonNotice('boolean', 1, 0)).toBe('已取消完成')
})
it('persists boolean display preferences across page refreshes', () => {
const storage = new Map<string, string>()
const fakeStorage = {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
}
expect(readStoredBoolean(fakeStorage, 'dodo.show-completed', true)).toBe(true)
writeStoredBoolean(fakeStorage, 'dodo.show-completed', false)
expect(readStoredBoolean(fakeStorage, 'dodo.show-completed', true)).toBe(false)
storage.set('dodo.show-completed', 'invalid')
expect(readStoredBoolean(fakeStorage, 'dodo.show-completed', true)).toBe(true)
})
it('reuses the current week habit grid while refreshing in the background', () => {
const habits = [{ id: 'habit-1', name: '喝水' }]
writeHabitGridCache('2026-09-07', habits)
expect(readHabitGridCache('2026-09-07')).toEqual(habits)
expect(readHabitGridCache('2026-09-14')).toBeNull()
})
it('reuses countdown data while the page refreshes in the background', () => {
const active = [{ id: 'countdown-1', title: '旅行' }]
const archived = [{ id: 'countdown-2', title: '旧日期' }]
writeCountdownCache(active, archived)
expect(readCountdownCache()).toEqual({ items: active, archived })
})
it('renders countdown labels from date-only day values', () => {
expect(countdownDayText(8)).toBe('还有 8 天')
expect(countdownDayText(0)).toBe('就是今天')
expect(countdownDayText(-12)).toBe('已经 12 天')
expect(countdownKindLabel('countdown')).toBe('倒数日')
expect(countdownKindLabel('anniversary')).toBe('纪念日')
expect(countdownKindLabel('birthday')).toBe('生日')
expect(calendarModeLabel('solar')).toBe('公历')
expect(calendarModeLabel('lunar')).toBe('农历')
})
it('toggles a row for a deliberate mostly-horizontal swipe', () => {
expect(shouldToggleRowSwipe(78, 8)).toBe(true)
expect(shouldToggleRowSwipe(88, 28)).toBe(true)
expect(shouldToggleRowSwipe(-78, 8)).toBe(false)
expect(shouldToggleRowSwipe(30, 2)).toBe(false)
expect(shouldToggleRowSwipe(80, 60)).toBe(false)
})
it('keeps the draggable mobile add button inside the viewport', () => {
expect(clampFabPosition(-10, -20, 390, 844)).toEqual({ x: 14, y: 14 })
expect(clampFabPosition(500, 900, 390, 844)).toEqual({ x: 320, y: 704 })
expect(clampFabPosition(120, 300, 390, 844)).toEqual({ x: 120, y: 300 })
})
it('snaps the add button to the nearest horizontal edge while preserving its safe vertical position', () => {
expect(snapFabPosition(40, 300, 390, 844, 56, 14, 84)).toEqual({ x: 14, y: 300 })
expect(snapFabPosition(280, 300, 390, 844, 56, 14, 84)).toEqual({ x: 320, y: 300 })
expect(snapFabPosition(180, 900, 390, 844, 56, 14, 84)).toEqual({ x: 320, y: 704 })
})
it('validates safe habit input and emits only changed patch fields', () => {
expect(normalizeRequiredName(' 喝水 ')).toEqual({ value: '喝水', error: '' })
expect(normalizeRequiredName(' \n ')).toEqual({ value: '', error: '名称不能为空,请输入至少一个可见字符。' })
expect(validateHabitForm({ name: ' ', kind: 'numeric', target: 0, max_value: 0, schedule_type: 'daily' })).toMatchObject({ name: '请输入习惯名称', target: '目标值必须大于 0' })
expect(validateHabitForm({ name: '跑步', kind: 'numeric', target: 3, max_value: 2, schedule_type: 'daily' })).toMatchObject({ max_value: '最大值不能小于目标值' })
expect(validateHabitForm({ name: '跑步', kind: 'boolean', target: 1, max_value: 1, schedule_type: 'weekly', weekdays: [] })).toMatchObject({ weekdays: '至少选择一个星期' })
expect(validateHabitForm({ name: '跑步', kind: 'boolean', target: 1, max_value: 1, schedule_type: 'monthly', month_days: [1, 1] })).toMatchObject({ month_days: '日期不能重复' })
expect(validateHabitForm({ name: '跑步', kind: 'boolean', target: 1, max_value: 1, schedule_type: 'interval', interval_days: 0 })).toMatchObject({ interval_days: '间隔天数至少为 1' })
expect(changedHabitFields(
{ name: '跑步', kind: 'numeric', target: 3, max_value: 5, schedule_type: 'weekly', weekdays: [0, 2] },
{ name: '跑步', kind: 'numeric', target: 4, max_value: 5, schedule_type: 'weekly', weekdays: [0, 2] },
)).toEqual({ target: 4 })
})
it('maps habit conflicts and validation arrays to actionable Chinese errors', () => {
expect(formatHabitApiError('暂停日不可记录正向进度')).toBe('今天已暂停,不能记录进度。')
expect(formatHabitApiError('非计划日不可记录正向进度')).toBe('今天未安排该习惯,不能记录进度。')
expect(formatHabitApiError([{ loc: ['body', 'max_value'], msg: 'Input should be greater than 0' }])).toBe('最大值必须大于 0。')
expect(formatHabitApiError([{ loc: ['body', 'weekdays'], msg: 'bad' }])).toBe('每周计划至少选择一天,且不能重复。')
expect(formatHabitApiError({ detail: '请先归档再永久删除' })).toBe('请先归档该习惯,再永久删除。')
})
it('blocks writes on paused, unscheduled, and archived habit rows', () => {
expect(habitActionState({ scheduled: true, paused: false })).toEqual({ writable: true, reason: '' })
expect(habitActionState({ scheduled: true, paused: true })).toEqual({ writable: false, reason: '今天已暂停' })
expect(habitActionState({ scheduled: false, paused: false })).toEqual({ writable: false, reason: '今天未安排' })
expect(habitActionState({ scheduled: true, paused: false }, true)).toEqual({ writable: false, reason: '该习惯已归档' })
})
it('formats conservative device and browser summaries without leaking unknown user agents', () => {
expect(formatUserAgent('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Version/18.0 Mobile/15E148 Safari/604.1')).toBe('iPhone · Safari')
expect(formatUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/128.0 Safari/537.36')).toBe('Mac · Chrome')
expect(formatUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/128.0 Safari/537.36 Edg/128.0')).toBe('Windows · Edge')
expect(formatUserAgent('Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 Chrome/128.0 Mobile Safari/537.36')).toBe('Android 手机 · Chrome')
expect(formatUserAgent('Mozilla/5.0 (Linux; Android 14; SM-X710) AppleWebKit/537.36 Chrome/128.0 Safari/537.36')).toBe('Android 平板 · Chrome')
expect(formatUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/605.1.15 Version/17.0 Mobile/15E148 Safari/604.1')).toBe('iPad · Safari')
expect(formatUserAgent('curl/8.7.1')).toBe('未知设备 · 未知浏览器')
expect(formatUserAgent(null)).toBe('未知设备 · 未知浏览器')
})
it('formats zoned and timezone-less ISO values in the selected local timezone', () => {
expect(formatLocalShortDateTime('2026-12-31T23:30:00Z', { timeZone: 'Asia/Shanghai' })).toMatch(/2027\/1\/1\s+07:30/)
expect(formatLocalShortDateTime('2026-09-10T14:08:00+02:00', { timeZone: 'Asia/Shanghai' })).toMatch(/2026\/9\/10\s+20:08/)
expect(formatLocalShortDateTime('2026-09-10T14:08:00', { timeZone: 'UTC' })).toMatch(/2026\/9\/10\s+14:08/)
expect(formatLocalShortDateTime('not-a-date')).toBe('未知时间')
expect(formatLocalShortDateTime(null)).toBe('未知时间')
})
it('rejects calendar dates that Date would otherwise normalize', () => {
expect(formatLocalShortDateTime('2026-02-30T12:00:00', { timeZone: 'UTC' })).toBe('未知时间')
expect(formatLocalShortDateTime('2026-02-30T12:00:00Z', { timeZone: 'UTC' })).toBe('未知时间')
expect(formatLocalShortDateTime('2026-04-31T12:00:00+08:00', { timeZone: 'UTC' })).toBe('未知时间')
})
it('keeps a successful restore committed when the grid refresh fails', async () => {
const events: string[] = []
const result = await performHabitRestore({
restore: async () => { events.push('restored') },
commitRestore: () => { events.push('archive-removed') },
refreshGrid: async () => false,
})
expect(events).toEqual(['restored', 'archive-removed'])
expect(result).toEqual({ restored: true, refreshed: false })
})
it('invalidates a restored habit grid cache before reconciliation', () => {
writeHabitGridCache('2026-09-07', [{ id: 'restored-habit' }])
invalidateHabitGridCache()
expect(readHabitGridCache('2026-09-07')).toBeNull()
})
it('does not commit a failed restore or attempt a grid refresh', async () => {
const events: string[] = []
await expect(performHabitRestore({
restore: async () => { throw new Error('恢复失败') },
commitRestore: () => { events.push('archive-removed') },
refreshGrid: async () => { events.push('grid-refreshed') },
})).rejects.toThrow('恢复失败')
expect(events).toEqual([])
})
it('formats archive timestamps locally and rejects every malformed calendar value', () => {
expect(formatArchivedAt('2026-09-10T12:08:00Z', { timeZone: 'Asia/Shanghai' })).toMatch(/^归档于 2026\/9\/10 20:08$/)
for (const value of [null, '', 'not-a-date', '2026-02-30T12:00:00Z', '2026-13-01T00:00:00Z', '2026-09-10T25:00:00Z']) {
expect(formatArchivedAt(value, { timeZone: 'UTC' })).toBe('归档时间未知')
}
})
it('makes archive loading, error, and empty states mutually exclusive', () => {
expect(archivePanelFlags('loading', 0)).toEqual({ loading: true, error: false, empty: false, list: false })
expect(archivePanelFlags('error', 0)).toEqual({ loading: false, error: true, empty: false, list: false })
expect(archivePanelFlags('success', 0)).toEqual({ loading: false, error: false, empty: true, list: false })
expect(archivePanelFlags('success', 2)).toEqual({ loading: false, error: false, empty: false, list: true })
expect(archivePanelFlags('idle', 0)).toEqual({ loading: false, error: false, empty: false, list: false })
})
it('localizes known audit actions and entities and hides unknown actions', () => {
expect(['create', 'update', 'complete', 'delete', 'archive', 'restore', 'move', 'import'].map(formatAuditAction)).toEqual(['创建', '更新', '完成', '删除', '归档', '恢复', '移动', '导入'])
expect(['task', 'list', 'folder', 'countdown', 'backup'].map(formatAuditEntity)).toEqual(['任务', '清单', '文件夹', '倒数日', '备份'])
expect(formatAuditAction('launch')).toBe('其他操作')
expect(formatAuditAction(null)).toBe('其他操作')
expect(formatAuditEntity('unknown')).toBe('内容')
})
it('distinguishes tapping the mobile add button from dragging it', () => {
expect(isFabDrag(3, 4)).toBe(false)
expect(isFabDrag(8, 0)).toBe(true)
expect(isFabDrag(6, 7)).toBe(true)
})
})