200 lines
7.3 KiB
TypeScript
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: '' })
|
|
})
|
|
})
|