feat: refine archived habit management
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { calendarModeLabel, changedHabitFields, clampFabPosition, countdownDayText, countdownKindLabel, dateKey, defaultView, formatAuditAction, formatAuditEntity, formatHabitApiError, formatLocalShortDateTime, formatUserAgent, habitActionState, habitButtonNotice, habitButtonValue, habitWeek, isFabDrag, isHabitComplete, isHabitScheduledToday, isTaskView, nextHabitSwipeValue, nextTotalAfterLocalTaskAdd, normalizeRequiredName, numericHabitInputValue, previousHabitSwipeValue, quickTaskFields, readCountdownCache, readHabitGridCache, readStoredBoolean, readStoredNavigation, shouldToggleRowSwipe, validateHabitForm, writeCountdownCache, writeHabitGridCache, writeStoredBoolean, writeStoredNavigation } from './mvp-utils'
|
||||
import { archivePanelFlags, calendarModeLabel, changedHabitFields, clampFabPosition, countdownDayText, countdownKindLabel, dateKey, defaultView, formatArchivedAt, formatAuditAction, formatAuditEntity, formatHabitApiError, formatLocalShortDateTime, formatUserAgent, habitActionState, habitButtonNotice, habitButtonValue, habitWeek, invalidateHabitGridCache, isFabDrag, isHabitComplete, isHabitScheduledToday, isTaskView, nextHabitSwipeValue, nextTotalAfterLocalTaskAdd, normalizeRequiredName, numericHabitInputValue, performHabitRestore, 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', () => {
|
||||
@@ -196,6 +196,49 @@ describe('MVP view utilities', () => {
|
||||
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(['任务', '清单', '文件夹', '倒数日', '备份'])
|
||||
|
||||
@@ -269,6 +269,25 @@ export function writeHabitGridCache<T>(week: string, habits: T[]) {
|
||||
habitGridCache = { week, habits }
|
||||
}
|
||||
|
||||
export function invalidateHabitGridCache() {
|
||||
habitGridCache = null
|
||||
}
|
||||
|
||||
export async function performHabitRestore(actions: {
|
||||
restore: () => Promise<unknown>
|
||||
commitRestore: () => void
|
||||
refreshGrid: () => Promise<boolean | unknown>
|
||||
}) {
|
||||
await actions.restore()
|
||||
actions.commitRestore()
|
||||
try {
|
||||
const refreshed = await actions.refreshGrid()
|
||||
return { restored: true as const, refreshed: refreshed !== false }
|
||||
} catch {
|
||||
return { restored: true as const, refreshed: false }
|
||||
}
|
||||
}
|
||||
|
||||
export function readCountdownCache<T>() {
|
||||
if (!countdownCache) return null
|
||||
return { items: countdownCache.items as T[], archived: countdownCache.archived as T[] }
|
||||
@@ -369,6 +388,22 @@ export function habitActionState(cell?: { scheduled?: boolean; paused?: boolean
|
||||
return { writable: true, reason: '' }
|
||||
}
|
||||
|
||||
export type ArchivePanelState = 'idle' | 'loading' | 'success' | 'error'
|
||||
|
||||
export function archivePanelFlags(state: ArchivePanelState, count: number) {
|
||||
return {
|
||||
loading: state === 'loading',
|
||||
error: state === 'error',
|
||||
empty: state === 'success' && count === 0,
|
||||
list: state === 'success' && count > 0,
|
||||
}
|
||||
}
|
||||
|
||||
export function formatArchivedAt(value?: string | null, options: { timeZone?: string } = {}) {
|
||||
const formatted = formatLocalShortDateTime(value, options)
|
||||
return formatted === '未知时间' ? '归档时间未知' : `归档于 ${formatted}`
|
||||
}
|
||||
|
||||
export function formatUserAgent(userAgent?: string | null): string {
|
||||
const ua = userAgent?.trim() ?? ''
|
||||
let device = '未知设备'
|
||||
|
||||
@@ -219,18 +219,20 @@ describe('request generation protection', () => {
|
||||
describe('habit request boundaries', () => {
|
||||
it('never loads archived habits for embedded Today habits and loads them lazily on Habits', () => {
|
||||
const mounted = habits.slice(habits.indexOf('onMounted(() =>'), habits.indexOf('onBeforeUnmount(() =>'))
|
||||
const archiveBlock = habits.slice(habits.indexOf('async function archiveHabit'), habits.indexOf('async function deleteHabit'))
|
||||
const archiveBlock = habits.slice(habits.indexOf('async function archiveHabit'), habits.indexOf('async function restoreHabit'))
|
||||
const loadBlock = habits.slice(habits.indexOf('async function loadArchivedHabits'), habits.indexOf('async function toggleArchivedHabits'))
|
||||
expect(mounted).not.toContain('loadArchivedHabits()')
|
||||
expect(archiveBlock).toContain("if (props.view === 'habits' && showArchivedHabits.value)")
|
||||
expect(habits).toContain('if (showArchivedHabits.value && !archivedHabitsLoaded.value)')
|
||||
expect(habits).toContain("{{ archivedHabitsLoaded ? `已归档(${archivedHabits.length})` : '已归档' }}")
|
||||
expect(loadBlock).toContain("if (props.view !== 'habits'")
|
||||
expect(habits).toContain("archiveState === 'success' ? `已归档(${archivedHabits.length})` : '已归档'")
|
||||
})
|
||||
|
||||
it('does not render an empty archive until loading succeeds and allows collapse-reopen retry', () => {
|
||||
it('does not render an empty archive until loading succeeds and retries after failure', () => {
|
||||
const toggleBlock = habits.slice(habits.indexOf('async function toggleArchivedHabits'), habits.indexOf('function refreshHabitDay'))
|
||||
expect(toggleBlock).toContain('showArchivedHabits.value && !archivedHabitsLoaded.value')
|
||||
expect(habits).toContain('v-if="archivedHabitsLoaded && !archivedHabits.length && !busy"')
|
||||
expect(habits).not.toContain('v-if="!archivedHabits.length && !busy" class="empty-panel">暂无已归档习惯。')
|
||||
expect(toggleBlock).toContain("showArchivedHabits.value && archiveState.value !== 'success'")
|
||||
expect(habits).toContain('v-else-if="archiveFlags.empty"')
|
||||
expect(habits).toContain("v-else-if=\"archiveState === 'error'\"")
|
||||
expect(habits).toContain('@click="loadArchivedHabits">重试</button>')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user