fix: harden async task and habit mutations
ci / gitleaks (push) Successful in 1m50s
ci / docker (push) Successful in 9m26s

This commit is contained in:
2026-09-19 10:00:23 +08:00
parent 0baedbc99e
commit cec0a4418c
8 changed files with 1593 additions and 164 deletions
+28 -24
View File
@@ -363,9 +363,12 @@ describe('approved UI detail direction', () => {
for (const [, attributes] of buttons) expect(attributes).toMatch(/\btype="(?:button|submit)"/)
const removeBlock = app.slice(app.indexOf('async function removeSubtask('), app.indexOf('function closeTaskDetail('))
expect(removeBlock).toContain('if (taskDetailBusy.value) return')
expect(removeBlock).toContain("await api(`/tasks/${subtask.id}`, { method: 'DELETE' })")
expect(removeBlock).toContain('parent.subtasks = (parent.subtasks ?? []).filter')
expect(removeBlock).toContain('tasks.value = tasks.value.filter')
expect(removeBlock).toContain("() => api(`/tasks/${subtask.id}`, { method: 'DELETE' })")
expect(removeBlock).toContain('await taskMutationReconciler.run(')
expect(removeBlock).toContain('{ affectsTaskView: true }')
expect(removeBlock).not.toContain('affectsTrash: true')
expect(removeBlock).toContain('selectedTask.value.subtasks = (selectedTask.value.subtasks ?? []).filter')
expect(removeBlock).not.toContain('tasks.value = tasks.value.filter')
expect(removeBlock).not.toContain('selectedTask.value = null')
})
@@ -655,7 +658,7 @@ describe('mobile list row language', () => {
expect(css).toContain('.habit-detail-sheet{width:min(500px,100%);max-height:min(88dvh,760px);overflow:hidden')
expect(mvpPanel).toContain("habitHistoryNextTo.value = ''")
expect(mvpPanel).toContain('habitHistoryHasMore.value = false')
expect(mvpPanel).toContain('syncHabitHistoryToday(h)')
expect(mvpPanel).toContain('syncHabitHistoryToday(h, day)')
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('initial-focus="button[aria-label=\'关闭习惯详情\']"')
@@ -801,16 +804,14 @@ describe('task and habit row decoration', () => {
const restoreBlock = app.slice(app.indexOf('async function restoreTask'), app.indexOf('async function purgeTask'))
const purgeBlock = app.slice(app.indexOf('async function purgeTask'), app.indexOf('async function addSubtask'))
expect(loadBlock).toContain('return await runLatestRequest')
expect(mutationBlock).toContain('const result = await performTrashMutation(')
expect(mutationBlock).toContain('trash.value = trash.value.filter((item) => item.id !== task.id)')
expect(mutationBlock).toContain('totalTasks.value = nextTotalAfterLocalTaskRemoval(totalTasks.value)')
expect(mutationBlock).toContain('if (page.value > totalPages.value) page.value = totalPages.value')
expect(mutationBlock).toContain('if (!result.mutated)')
expect(mutationBlock).toContain('fail(result.error)')
expect(mutationBlock).toContain('if (!result.refreshed)')
expect(mutationBlock).toContain('请重试刷新')
expect(mutationBlock).toContain('await taskMutationReconciler.run(')
expect(mutationBlock).toContain('{ affectsTrash: true, affectsTaskView }')
expect(mutationBlock).not.toContain('performTrashMutation(')
expect(mutationBlock).not.toContain('loadTrash')
expect(restoreBlock).toContain("await mutateTrashTask(task, () => api(`/tasks/${task.id}/restore`")
expect(restoreBlock).toContain("'任务已恢复', true")
expect(purgeBlock).toContain("await mutateTrashTask(task, () => api(`/trash/${task.id}`")
expect(purgeBlock).toContain("'任务已永久删除', false")
})
it('supports pointer dragging for desktop task completion in regular and overdue rows', () => {
@@ -828,11 +829,12 @@ describe('task and habit row decoration', () => {
})
it('updates habit rows locally after swiping instead of refreshing the whole Today section', () => {
expect(mvpPanel).toContain('setLocalHabitValue(h, next)')
const applyBlock = mvpPanel.slice(mvpPanel.indexOf('async function applyHabitSwipe'), mvpPanel.indexOf('async function finishHabitSwipe'))
const mutationBlock = mvpPanel.slice(mvpPanel.indexOf('async function mutateHabitValue'), mvpPanel.indexOf('function suppressHabitDetailClick'))
const applyBlock = mvpPanel.slice(mvpPanel.indexOf('async function applyHabitSwipe'), mvpPanel.indexOf('function suppressHabitDetailClick'))
expect(mutationBlock).toContain('setLocalHabitValue(h, next, day)')
expect(mutationBlock).toContain('setLocalHabitValue(h, rollback, day)')
expect(applyBlock).not.toContain('await loadHabits()')
expect(applyBlock).toContain('setLocalHabitValue(h, next)')
expect(applyBlock).toContain('setLocalHabitValue(h, previous)')
expect(applyBlock).toContain('mutateHabitValue(h, next')
})
it('keeps swipe behavior without rendering the swipe background layer', () => {
@@ -882,10 +884,10 @@ describe('task and habit row decoration', () => {
it('reconciles concurrent completion mutations without suppressing their feedback', () => {
const toggleBlock = app.slice(app.indexOf('async function toggle(task: Task)'), app.indexOf('function isInteractiveTarget'))
expect(toggleBlock).toContain('const completing = !task.completed')
expect(toggleBlock).toContain('await taskMutationReconciler.run(')
expect(toggleBlock).toContain("() => toast(completing ? '完成啦' : '已重新打开')")
expect(toggleBlock).toContain('applyTaskUpdate(task, updated)')
expect(toggleBlock).toContain('const completing = updated.completed')
expect(toggleBlock).toContain('await taskToggleCoordinator.toggle(')
expect(toggleBlock).toContain("toast(completing ? '完成啦' : '已重新打开')")
expect(toggleBlock).toContain('applyTaskUpdate(task, updated, !preserveSelectedDraft)')
expect(toggleBlock).not.toContain("beginLatestRequest('tasks')")
})
@@ -897,8 +899,7 @@ describe('task and habit row decoration', () => {
expect(css).toContain('.habit-check{')
expect(mvpPanel).toContain('@pointerdown="startHabitPointer')
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).toContain('mutateHabitValue(h, next')
expect(toggleBlock).not.toContain('await loadHabits()')
})
@@ -1038,7 +1039,10 @@ describe('task and habit row decoration', () => {
expect(app).toContain('return Boolean(dueToday || completedToday)')
expect(app).toContain("params.set('completed_from', isoAtLocalDayOffset(0))")
expect(app).toContain("params.set('completed_to', isoAtLocalDayOffset(1))")
expect(app).toContain("if (activeView.value === 'today' && showCompleted.value) await loadAll()")
const toggleBlock = app.slice(app.indexOf('async function toggle(task: Task)'), app.indexOf('function isInteractiveTarget'))
expect(toggleBlock).toContain('reconcile: async (confirmed, ownership) =>')
expect(toggleBlock).toContain('await reconcileCurrentViewAfterTaskMutation()')
expect(toggleBlock).not.toContain('await loadAll()')
})
it('reclassifies Today tasks after due edits without toggling page loading', () => {
@@ -1203,7 +1207,7 @@ describe('approved habit safety and U2 title hierarchy', () => {
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('setLocalHabitValue(h, rollback, day)')
expect(mvpPanel).toContain('formatHabitApiError')
})
})