Files
dodo/frontend/src/current-view-task-reconciliation.test.ts
T
bboysoul cec0a4418c
ci / gitleaks (push) Successful in 1m50s
ci / docker (push) Successful in 9m26s
fix: harden async task and habit mutations
2026-09-19 10:00:23 +08:00

200 lines
7.3 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import { createMutationReconciler, reconcileCurrentTaskView } from './lib/mvp-utils'
type ViewContext = { navigation: number; view: 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits'; listId: string }
type State = { tasks: string[]; total: number; loading: boolean; error: string }
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((res) => { resolve = res })
return { promise, resolve }
}
const sameContext = (left: ViewContext, right: ViewContext) =>
left.navigation === right.navigation && left.view === right.view && left.listId === right.listId
function mutationHarness() {
let current: ViewContext = { navigation: 1, view: 'tasks', listId: 'inbox' }
const state: State = { tasks: ['inbox task'], total: 1, loading: false, error: '' }
const loadTaskView = vi.fn(async (context: ViewContext, ownership: { current: () => boolean }) => {
if (ownership.current()) Object.assign(state, { tasks: [`${context.listId} task`], total: 1, loading: false, error: '' })
})
const loadTrash = vi.fn(async (ownership: { current: () => boolean }) => {
if (ownership.current()) Object.assign(state, { tasks: ['trashed task'], total: 7, loading: false, error: '' })
})
const reconcileCurrentView = (options: { affectsTrash?: boolean; affectsTaskView?: boolean } = {}) => reconcileCurrentTaskView({
capture: () => current,
isTaskBacked: context => ['tasks', 'today', 'upcoming'].includes(context.view),
isTrash: context => context.view === 'trash',
sameContext,
loadTaskView,
loadTrash,
affectsTrash: options.affectsTrash,
affectsTaskView: options.affectsTaskView,
})
const reconciler = createMutationReconciler(
() => current,
sameContext,
reconcileCurrentView,
)
return {
state,
loadTaskView,
loadTrash,
reconciler,
navigate(next: ViewContext, nextState: State) {
current = next
Object.assign(state, nextState)
},
}
}
describe('current-view-aware task mutation reconciliation', () => {
it.each([
{ mutation: 'create', affectsTrash: false },
{ mutation: 'add subtask', affectsTrash: false },
{ mutation: 'delete', affectsTrash: true },
{ mutation: 'remove subtask', affectsTrash: true },
])('keeps resolved Trash authoritative after a stale $mutation settles', async ({ affectsTrash }) => {
const harness = mutationHarness()
const request = deferred<void>()
const pending = harness.reconciler.run(
() => request.promise,
() => undefined,
undefined,
undefined,
{ affectsTrash },
)
harness.navigate(
{ navigation: 2, view: 'trash', listId: 'inbox' },
{ tasks: ['target trash'], total: 9, loading: false, error: 'target error' },
)
request.resolve()
await pending
expect(harness.loadTaskView).not.toHaveBeenCalled()
if (affectsTrash) {
expect(harness.loadTrash).toHaveBeenCalledOnce()
expect(harness.state).toEqual({ tasks: ['trashed task'], total: 7, loading: false, error: '' })
} else {
expect(harness.loadTrash).not.toHaveBeenCalled()
expect(harness.state).toEqual({ tasks: ['target trash'], total: 9, loading: false, error: 'target error' })
}
})
it.each(['create', 'delete', 'add subtask', 'remove subtask'])('keeps a non-task view authoritative after a stale %s settles', async () => {
const harness = mutationHarness()
const request = deferred<void>()
const pending = harness.reconciler.run(
() => request.promise,
() => undefined,
undefined,
undefined,
{ affectsTrash: true },
)
harness.navigate(
{ navigation: 2, view: 'habits', listId: 'inbox' },
{ tasks: ['habit-owned state'], total: 4, loading: false, error: 'habit error' },
)
request.resolve()
await pending
expect(harness.loadTaskView).not.toHaveBeenCalled()
expect(harness.loadTrash).not.toHaveBeenCalled()
expect(harness.state).toEqual({ tasks: ['habit-owned state'], total: 4, loading: false, error: 'habit error' })
})
it('does not reload Trash when a stale subtask removal settles', async () => {
const harness = mutationHarness()
const request = deferred<void>()
const pending = harness.reconciler.run(
() => request.promise,
() => undefined,
undefined,
undefined,
{ affectsTrash: false },
)
harness.navigate(
{ navigation: 2, view: 'trash', listId: 'inbox' },
{ tasks: ['target trash'], total: 9, loading: false, error: 'target error' },
)
request.resolve()
await pending
expect(harness.loadTaskView).not.toHaveBeenCalled()
expect(harness.loadTrash).not.toHaveBeenCalled()
expect(harness.state).toEqual({ tasks: ['target trash'], total: 9, loading: false, error: 'target error' })
})
it.each([
{ mutation: 'restore', affectsTaskView: true, expectedTaskLoads: 1 },
{ mutation: 'purge', affectsTaskView: false, expectedTaskLoads: 0 },
])('reconciles stale Trash $mutation only when it can affect the new task-backed view', async ({ affectsTaskView, expectedTaskLoads }) => {
const harness = mutationHarness()
const request = deferred<void>()
const pending = harness.reconciler.run(
() => request.promise,
() => undefined,
undefined,
undefined,
{ affectsTrash: true, affectsTaskView },
)
harness.navigate(
{ navigation: 2, view: 'tasks', listId: 'work' },
{ tasks: ['target work'], total: 3, loading: false, error: '' },
)
request.resolve()
await pending
expect(harness.loadTaskView).toHaveBeenCalledTimes(expectedTaskLoads)
expect(harness.loadTrash).not.toHaveBeenCalled()
expect(harness.state).toEqual(expectedTaskLoads
? { tasks: ['work task'], total: 1, loading: false, error: '' }
: { tasks: ['target work'], total: 3, loading: false, error: '' })
})
it.each(['restore', 'purge'])('leaves a non-task view untouched when stale Trash %s settles', async () => {
const harness = mutationHarness()
const request = deferred<void>()
const pending = harness.reconciler.run(
() => request.promise,
() => undefined,
undefined,
undefined,
{ affectsTrash: true, affectsTaskView: true },
)
harness.navigate(
{ navigation: 2, view: 'habits', listId: 'inbox' },
{ tasks: ['habit-owned state'], total: 4, loading: false, error: 'habit error' },
)
request.resolve()
await pending
expect(harness.loadTaskView).not.toHaveBeenCalled()
expect(harness.loadTrash).not.toHaveBeenCalled()
expect(harness.state).toEqual({ tasks: ['habit-owned state'], total: 4, loading: false, error: 'habit error' })
})
it('refreshes the currently visible task-backed list after a stale mutation settles', async () => {
const harness = mutationHarness()
const request = deferred<void>()
const pending = harness.reconciler.run(() => request.promise, () => undefined)
harness.navigate(
{ navigation: 2, view: 'tasks', listId: 'work' },
{ tasks: ['target work'], total: 3, loading: false, error: '' },
)
request.resolve()
await pending
expect(harness.loadTaskView).toHaveBeenCalledOnce()
expect(harness.loadTrash).not.toHaveBeenCalled()
expect(harness.state).toEqual({ tasks: ['work task'], total: 1, loading: false, error: '' })
})
})