fix: harden habit input and lifecycle
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 3m21s

This commit is contained in:
2026-09-09 14:08:19 +08:00
parent 489d120159
commit bc4c281658
10 changed files with 735 additions and 83 deletions
+60 -7
View File
@@ -97,9 +97,9 @@ describe('mobile sheet contract', () => {
expect(css).toContain('padding-bottom:calc(16px + env(safe-area-inset-bottom))')
})
it('keeps destructive actions in a separate bottom danger zone', () => {
expect(mvpPanel).toContain('class="app-sheet__danger"')
expect(mvpPanel).toMatch(/app-sheet__danger[\s\S]*?deleteHabit\(selectedHabit\)/)
it('keeps the danger zone reserved for archived habit deletion', () => {
expect(mvpPanel).toContain('v-if="selectedHabit.archived_at" class="app-sheet__danger"')
expect(mvpPanel).toContain('deleteHabit(selectedHabit)')
expect(css).toContain('.app-sheet__danger{border-top:1px solid #f1d4cd;')
})
})
@@ -117,19 +117,35 @@ describe('mobile list row language', () => {
expect(css).toContain('.habit-progress{font-size:16px}')
})
it('keeps destructive habit actions in a detail sheet instead of the list row', () => {
it('offers edit and archive on active detail, with permanent delete only on archived detail', () => {
expect(mvpPanel).not.toContain('<button class="icon ghost" aria-label="归档习惯"')
expect(mvpPanel).toContain('class="habit-detail-mask app-sheet-mask"')
expect(mvpPanel).toContain('class="habit-detail-sheet app-sheet app-sheet--detail"')
expect(mvpPanel).toContain('@click="editHabit(selectedHabit)"')
expect(mvpPanel).toContain('@click="archiveHabit(selectedHabit)"')
expect(mvpPanel).toContain('v-if="selectedHabit.archived_at"')
expect(mvpPanel).toContain('@click="deleteHabit(selectedHabit)"')
expect(mvpPanel).toContain('永久删除')
expect(mvpPanel).toContain("request(`/habits/${h.id}/permanent`, { method: 'DELETE' })")
expect(mvpPanel).toContain('v-if="!selectedHabit.archived_at"')
expect(mvpPanel).toContain('@click="openHabitDetail(h, $event.currentTarget as HTMLElement)"')
expect(mvpPanel).toContain('@keydown.enter.prevent="openHabitDetail(h, $event.currentTarget as HTMLElement)"')
expect(mvpPanel).toContain('ref="habitDetailSheet"')
expect(mvpPanel).toContain("habitDetailSheet.value?.focus()")
})
it('provides a minimal archived-habit viewing path', () => {
expect(mvpPanel).toContain("request('/habits?archived=true')")
expect(mvpPanel).toContain('已归档({{ archivedHabits.length }}')
expect(mvpPanel).toContain('v-for="h in archivedHabits"')
})
it('reuses the habit composer for edits and patches only changed fields', () => {
expect(mvpPanel).toContain('const editingHabit = ref<Habit | null>(null)')
expect(mvpPanel).toContain('changedHabitFields(originalHabitForm.value, normalized)')
expect(mvpPanel).toContain("method: 'PATCH'")
expect(mvpPanel).toContain("habitComposerTitle")
expect(mvpPanel).toContain("habitFormError.value = reason instanceof Error")
})
})
describe('task and habit row decoration', () => {
@@ -228,7 +244,7 @@ describe('task and habit row decoration', () => {
expect(mvpPanel.match(/<Check v-if="isDone\(h, todayKey\)"/g)?.length).toBe(2)
expect(css).toContain('.habit-check{')
expect(mvpPanel).toContain('@pointerdown="startHabitPointer')
const toggleBlock = mvpPanel.slice(mvpPanel.indexOf('async function toggleHabitFromButton'), mvpPanel.indexOf('async function addHabit'))
const toggleBlock = mvpPanel.slice(mvpPanel.indexOf('async function toggleHabitFromButton'), mvpPanel.indexOf('function currentHabitForm'))
expect(toggleBlock).toContain('setLocalHabitValue(h, next)')
expect(toggleBlock).toContain('setLocalHabitValue(h, previous)')
expect(toggleBlock).not.toContain('await loadHabits()')
@@ -395,9 +411,46 @@ describe('mobile touch targets', () => {
})
})
describe('approved habit safety and U2 title hierarchy', () => {
it('keeps one page title and upgrades settings card headings without changing the card class', () => {
expect(mvpPanel).not.toContain('<h2>习惯</h2>')
expect(mvpPanel).not.toContain('<h2>设置与数据</h2>')
expect(mvpPanel).toContain('<h2>数据导出与恢复</h2>')
expect(mvpPanel).toContain('<h2>修改密码</h2>')
expect(mvpPanel).toContain('<h2>登录会话</h2>')
expect(mvpPanel).toContain('<h2>最近活动</h2>')
expect(css).toContain('.tool-card>h2{')
})
it('keeps invalid forms visible, disables save, and still shows the reason', () => {
expect(app).toContain('const modalError = ref')
expect(app).toContain('role="alert" class="field-error"')
expect(app).toContain('normalizeRequiredName')
expect(mvpPanel).toContain('habitErrors.name')
expect(mvpPanel).toContain('aria-describedby="habit-name-error"')
expect(mvpPanel).toContain('const habitFormInvalid = computed')
expect(mvpPanel).toContain(':disabled="busy || habitFormInvalid"')
expect(mvpPanel).toContain('请修正表单中的错误后再保存')
})
it('keeps the 390px habit sheets full width with 44px close and bottom actions', () => {
expect(css).toContain('@media(max-width:390px){.habit-detail-sheet,.habit-compose-sheet{width:100%;max-width:100%}')
expect(css).toContain('.habit-detail-sheet .app-sheet__header>button,.habit-compose-sheet .app-sheet__header>button{min-width:44px;min-height:44px}')
expect(css).toContain('.habit-detail-sheet .app-sheet__footer>button,.habit-detail-sheet .app-sheet__danger>button,.habit-compose-sheet .app-sheet__footer>button{min-height:44px}')
})
it('disables illegal habit writes and keeps optimistic rollback', () => {
expect(mvpPanel).toContain('habitAction(h)')
expect(mvpPanel).toContain(':disabled="!habitAction(h).writable"')
expect(mvpPanel).toContain(':aria-disabled="!habitAction(h).writable"')
expect(mvpPanel).toContain('setLocalHabitValue(h, previous)')
expect(mvpPanel).toContain('formatHabitApiError')
})
})
describe('settings data tools', () => {
it('keeps backup export and restore but removes the standalone import tool', () => {
expect(mvpPanel).toContain('<h3>数据导出与恢复</h3>')
expect(mvpPanel).toContain('<h2>数据导出与恢复</h2>')
expect(mvpPanel).toContain("fetch('/api/v1/export.csv'")
expect(mvpPanel).toContain("'dodo-export.csv'")
expect(mvpPanel).toContain('导出 CSV')