fix: harden async task and habit mutations
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
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: '' })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user