fix: harden habit input and lifecycle
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { calendarModeLabel, clampFabPosition, countdownDayText, countdownKindLabel, dateKey, defaultView, habitButtonNotice, habitButtonValue, habitWeek, isFabDrag, isHabitComplete, isHabitScheduledToday, isTaskView, nextHabitSwipeValue, nextTotalAfterLocalTaskAdd, numericHabitInputValue, previousHabitSwipeValue, quickTaskFields, readCountdownCache, readHabitGridCache, readStoredBoolean, readStoredNavigation, shouldToggleRowSwipe, writeCountdownCache, writeHabitGridCache, writeStoredBoolean, writeStoredNavigation } from './mvp-utils'
|
||||
import { calendarModeLabel, changedHabitFields, clampFabPosition, countdownDayText, countdownKindLabel, dateKey, defaultView, formatHabitApiError, 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'
|
||||
|
||||
describe('MVP view utilities', () => {
|
||||
it('formats a local date as YYYY-MM-DD', () => {
|
||||
@@ -142,6 +142,35 @@ describe('MVP view utilities', () => {
|
||||
expect(clampFabPosition(120, 300, 390, 844)).toEqual({ x: 120, y: 300 })
|
||||
})
|
||||
|
||||
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('distinguishes tapping the add button from dragging it', () => {
|
||||
expect(isFabDrag(3, 4)).toBe(false)
|
||||
expect(isFabDrag(8, 0)).toBe(true)
|
||||
|
||||
@@ -171,6 +171,84 @@ export function writeCountdownCache<T>(items: T[], archived: T[]) {
|
||||
countdownCache = { items, archived }
|
||||
}
|
||||
|
||||
export type HabitFormValues = {
|
||||
name: string
|
||||
kind: 'boolean' | 'numeric'
|
||||
target: number
|
||||
max_value?: number | null
|
||||
schedule_type: 'daily' | 'weekly' | 'monthly' | 'interval'
|
||||
weekdays?: number[] | null
|
||||
month_days?: number[] | null
|
||||
interval_days?: number | null
|
||||
}
|
||||
|
||||
export type HabitFormErrors = Partial<Record<keyof HabitFormValues, string>>
|
||||
|
||||
export function normalizeRequiredName(input: string) {
|
||||
const value = input.trim()
|
||||
return { value, error: value ? '' : '名称不能为空,请输入至少一个可见字符。' }
|
||||
}
|
||||
|
||||
export function validateHabitForm(form: HabitFormValues): HabitFormErrors {
|
||||
const errors: HabitFormErrors = {}
|
||||
if (!form.name.trim()) errors.name = '请输入习惯名称'
|
||||
if (form.kind === 'numeric') {
|
||||
if (!Number.isFinite(Number(form.target)) || Number(form.target) <= 0) errors.target = '目标值必须大于 0'
|
||||
if (form.max_value != null && (!Number.isFinite(Number(form.max_value)) || Number(form.max_value) <= 0 || Number(form.max_value) < Number(form.target))) errors.max_value = '最大值不能小于目标值'
|
||||
}
|
||||
if (form.schedule_type === 'weekly') {
|
||||
const values = form.weekdays ?? []
|
||||
if (!values.length) errors.weekdays = '至少选择一个星期'
|
||||
else if (new Set(values).size !== values.length) errors.weekdays = '星期不能重复'
|
||||
}
|
||||
if (form.schedule_type === 'monthly') {
|
||||
const values = form.month_days ?? []
|
||||
if (!values.length || values.some((day) => !Number.isInteger(day) || day < 1 || day > 31)) errors.month_days = '请输入 1 到 31 的日期'
|
||||
else if (new Set(values).size !== values.length) errors.month_days = '日期不能重复'
|
||||
}
|
||||
if (form.schedule_type === 'interval' && (!Number.isInteger(Number(form.interval_days)) || Number(form.interval_days) < 1)) errors.interval_days = '间隔天数至少为 1'
|
||||
return errors
|
||||
}
|
||||
|
||||
export function changedHabitFields(before: HabitFormValues, after: HabitFormValues) {
|
||||
const result: Partial<HabitFormValues> = {}
|
||||
for (const key of Object.keys(after) as Array<keyof HabitFormValues>) {
|
||||
const oldValue = before[key]
|
||||
const newValue = after[key]
|
||||
if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) (result as Record<string, unknown>)[key] = newValue
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function habitActionState(cell?: { scheduled?: boolean; paused?: boolean }, archived = false) {
|
||||
if (archived) return { writable: false, reason: '该习惯已归档' }
|
||||
if (cell?.paused) return { writable: false, reason: '今天已暂停' }
|
||||
if (!cell?.scheduled) return { writable: false, reason: '今天未安排' }
|
||||
return { writable: true, reason: '' }
|
||||
}
|
||||
|
||||
export function formatHabitApiError(detail: unknown): string {
|
||||
const source = detail && typeof detail === 'object' && !Array.isArray(detail) && 'detail' in detail
|
||||
? (detail as Record<string, unknown>).detail
|
||||
: detail
|
||||
const text = typeof source === 'string' ? source : ''
|
||||
const mappings: Array<[string, string]> = [
|
||||
['暂停日不可记录正向进度', '今天已暂停,不能记录进度。'],
|
||||
['非计划日不可记录正向进度', '今天未安排该习惯,不能记录进度。'],
|
||||
['归档习惯不可修改', '该习惯已归档,不能继续操作。'],
|
||||
['请先归档再永久删除', '请先归档该习惯,再永久删除。'],
|
||||
]
|
||||
for (const [needle, message] of mappings) if (text.includes(needle)) return message
|
||||
if (Array.isArray(source)) {
|
||||
const field = source.map((item) => item && typeof item === 'object' && Array.isArray((item as Record<string, unknown>).loc) ? ((item as Record<string, unknown>).loc as unknown[]).at(-1) : '').find(Boolean)
|
||||
const fieldMessages: Record<string, string> = {
|
||||
name: '名称不能为空,请输入至少一个可见字符。', weekdays: '每周计划至少选择一天,且不能重复。', month_days: '每月日期必须是 1–31,且不能重复。', interval_days: '间隔天数至少为 1。', target: '目标值必须大于 0。', max_value: '最大值必须大于 0。',
|
||||
}
|
||||
if (typeof field === 'string' && fieldMessages[field]) return fieldMessages[field]
|
||||
}
|
||||
return formatApiErrorDetail(source)
|
||||
}
|
||||
|
||||
export function formatApiErrorDetail(detail: unknown): string {
|
||||
if (typeof detail === 'string') return detail
|
||||
if (Array.isArray(detail)) {
|
||||
|
||||
Reference in New Issue
Block a user