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