335 lines
14 KiB
TypeScript
335 lines
14 KiB
TypeScript
import { readFileSync } from 'node:fs'
|
||
import { describe, expect, it, vi } from 'vitest'
|
||
import {
|
||
beginLatestRequest,
|
||
captureRequestContext,
|
||
commitIfRequestContextCurrent,
|
||
getCountdownCacheGeneration,
|
||
invalidateCountdownCache,
|
||
isCountdownCacheGenerationCurrent,
|
||
isLatestRequest,
|
||
loadCountdownCache,
|
||
readCountdownCache,
|
||
runLatestRequest,
|
||
startPrimaryWithBackground,
|
||
createMutationReconciler,
|
||
} from './mvp-utils'
|
||
|
||
const app = readFileSync('src/App.vue', 'utf8')
|
||
const habits = readFileSync('src/MvpPanel.vue', 'utf8')
|
||
|
||
describe('request generation protection', () => {
|
||
it('allows only the latest task request to commit data, loading, and errors', () => {
|
||
const first = beginLatestRequest('tasks')
|
||
const second = beginLatestRequest('tasks')
|
||
expect(isLatestRequest('tasks', first)).toBe(false)
|
||
expect(isLatestRequest('tasks', second)).toBe(true)
|
||
})
|
||
|
||
it('does not let a completed mutation supersede newer navigation state', async () => {
|
||
const task = beginLatestRequest('tasks')
|
||
const mutation = captureRequestContext('tasks')
|
||
const events: string[] = []
|
||
let finishMutation!: () => void
|
||
const pendingMutation = new Promise<void>((resolve) => { finishMutation = resolve }).then(() => {
|
||
commitIfRequestContextCurrent(mutation, () => events.push('mutation:refresh'))
|
||
})
|
||
|
||
const navigation = beginLatestRequest('tasks')
|
||
finishMutation()
|
||
await pendingMutation
|
||
|
||
expect(isLatestRequest('tasks', task)).toBe(false)
|
||
expect(isLatestRequest('tasks', navigation)).toBe(true)
|
||
expect(events).toEqual([])
|
||
})
|
||
|
||
it('keeps both success notifications and performs a final refresh for out-of-order mutations', async () => {
|
||
const events: string[] = []
|
||
const context = { view: 'today', list: 'inbox' }
|
||
let resolveFirst!: () => void
|
||
let resolveSecond!: () => void
|
||
let releaseRefresh!: () => void
|
||
const reconciler = createMutationReconciler(
|
||
() => context,
|
||
(left, right) => left.view === right.view && left.list === right.list,
|
||
() => new Promise<void>((resolve) => { events.push('refresh'); releaseRefresh = resolve }),
|
||
)
|
||
|
||
const first = reconciler.run(
|
||
() => new Promise<void>((resolve) => { resolveFirst = resolve }),
|
||
() => events.push('success:first'),
|
||
)
|
||
const second = reconciler.run(
|
||
() => new Promise<void>((resolve) => { resolveSecond = resolve }),
|
||
() => events.push('success:second'),
|
||
)
|
||
|
||
resolveSecond()
|
||
await Promise.resolve()
|
||
expect(events).toEqual(['success:second', 'refresh'])
|
||
resolveFirst()
|
||
await Promise.resolve()
|
||
expect(events).toEqual(['success:second', 'refresh', 'success:first'])
|
||
releaseRefresh()
|
||
await Promise.resolve()
|
||
expect(events).toEqual(['success:second', 'refresh', 'success:first', 'refresh'])
|
||
releaseRefresh()
|
||
await Promise.all([first, second])
|
||
})
|
||
|
||
it('waits for current-context success work before refreshing', async () => {
|
||
const events: string[] = []
|
||
let releaseExit!: () => void
|
||
const reconciler = createMutationReconciler(
|
||
() => ({ view: 'today' }),
|
||
(left, right) => left.view === right.view,
|
||
async () => { events.push('refresh') },
|
||
)
|
||
const mutation = reconciler.run(
|
||
async () => 'done',
|
||
() => events.push('success'),
|
||
undefined,
|
||
async () => {
|
||
events.push('exit:start')
|
||
await new Promise<void>((resolve) => { releaseExit = resolve })
|
||
events.push('exit:end')
|
||
},
|
||
)
|
||
await Promise.resolve()
|
||
expect(events).toEqual(['success', 'exit:start'])
|
||
releaseExit()
|
||
await mutation
|
||
expect(events).toEqual(['success', 'exit:start', 'exit:end', 'refresh'])
|
||
})
|
||
|
||
it('refreshes again when the first refresh finishes before the second mutation commits', async () => {
|
||
const events: string[] = []
|
||
const context = { view: 'today' }
|
||
let resolveFirstMutation!: () => void
|
||
let resolveSecondMutation!: () => void
|
||
let resolveFirstRefresh!: () => void
|
||
const reconciler = createMutationReconciler(
|
||
() => context,
|
||
(left, right) => left.view === right.view,
|
||
() => {
|
||
events.push('refresh:start')
|
||
if (events.filter((event) => event === 'refresh:start').length === 1) {
|
||
return new Promise<void>((resolve) => { resolveFirstRefresh = resolve })
|
||
}
|
||
events.push('refresh:second-done')
|
||
return Promise.resolve()
|
||
},
|
||
)
|
||
|
||
const first = reconciler.run(
|
||
() => new Promise<void>((resolve) => { resolveFirstMutation = resolve }),
|
||
() => events.push('success:first'),
|
||
)
|
||
const second = reconciler.run(
|
||
() => new Promise<void>((resolve) => { resolveSecondMutation = resolve }),
|
||
() => events.push('success:second'),
|
||
)
|
||
resolveFirstMutation()
|
||
await Promise.resolve()
|
||
expect(events).toEqual(['success:first', 'refresh:start'])
|
||
resolveFirstRefresh()
|
||
await first
|
||
resolveSecondMutation()
|
||
await second
|
||
expect(events).toEqual(['success:first', 'refresh:start', 'success:second', 'refresh:start', 'refresh:second-done'])
|
||
})
|
||
|
||
it('keeps success feedback but skips reconciliation after navigation changes', async () => {
|
||
const events: string[] = []
|
||
let context = { view: 'today' }
|
||
let resolveMutation!: () => void
|
||
const reconciler = createMutationReconciler(
|
||
() => context,
|
||
(left, right) => left.view === right.view,
|
||
async () => { events.push('refresh') },
|
||
)
|
||
const mutation = reconciler.run(
|
||
() => new Promise<void>((resolve) => { resolveMutation = resolve }),
|
||
() => events.push('success'),
|
||
)
|
||
context = { view: 'tasks' }
|
||
resolveMutation()
|
||
await mutation
|
||
expect(events).toEqual(['success'])
|
||
})
|
||
|
||
it('prevents stale Trash success, error, and finally callbacks from committing', async () => {
|
||
const events: string[] = []
|
||
let finishOld!: (value: string) => void
|
||
const oldRequest = runLatestRequest('trash',
|
||
() => new Promise<string>((resolve) => { finishOld = resolve }),
|
||
{
|
||
success: (value) => events.push(`old:${value}`),
|
||
error: () => events.push('old:error'),
|
||
finally: () => events.push('old:finally'),
|
||
},
|
||
)
|
||
const currentRequest = runLatestRequest('trash',
|
||
async () => 'new',
|
||
{
|
||
success: (value) => events.push(`new:${value}`),
|
||
error: () => events.push('new:error'),
|
||
finally: () => events.push('new:finally'),
|
||
},
|
||
)
|
||
await currentRequest
|
||
finishOld('stale')
|
||
await oldRequest
|
||
expect(events).toEqual(['new:new', 'new:finally'])
|
||
})
|
||
|
||
it('returns whether a latest request committed successfully', async () => {
|
||
await expect(runLatestRequest('trash-result-success', async () => 'ok', {
|
||
success: () => undefined,
|
||
error: () => undefined,
|
||
finally: () => undefined,
|
||
})).resolves.toBe(true)
|
||
await expect(runLatestRequest('trash-result-failure', async () => { throw new Error('offline') }, {
|
||
success: () => undefined,
|
||
error: () => undefined,
|
||
finally: () => undefined,
|
||
})).resolves.toBe(false)
|
||
})
|
||
|
||
it('returns false when a request becomes stale before it settles', async () => {
|
||
let finish!: () => void
|
||
const request = runLatestRequest('trash-result-stale', () => new Promise<void>((resolve) => { finish = resolve }), {
|
||
success: () => undefined,
|
||
error: () => undefined,
|
||
finally: () => undefined,
|
||
})
|
||
beginLatestRequest('trash-result-stale')
|
||
finish()
|
||
await expect(request).resolves.toBe(false)
|
||
})
|
||
|
||
it('prevents a stale Trash rejection from surfacing after a new view starts', async () => {
|
||
const events: string[] = []
|
||
let rejectOld!: (reason: Error) => void
|
||
const oldRequest = runLatestRequest('trash',
|
||
() => new Promise<void>((_resolve, reject) => { rejectOld = reject }),
|
||
{
|
||
success: () => events.push('old:success'),
|
||
error: () => events.push('old:error'),
|
||
finally: () => events.push('old:finally'),
|
||
},
|
||
)
|
||
beginLatestRequest('trash')
|
||
rejectOld(new Error('stale trash failure'))
|
||
await oldRequest
|
||
expect(events).toEqual([])
|
||
})
|
||
|
||
it('wires Trash loading through its own generation and invalidates it on exit', () => {
|
||
const trashBlock = app.slice(app.indexOf('async function loadTrash()'), app.indexOf('async function switchView'))
|
||
const switchBlock = app.slice(app.indexOf('async function switchView'), app.indexOf('async function loadTodayView'))
|
||
expect(trashBlock).toContain("await runLatestRequest('trash', loadTrashPage")
|
||
expect(trashBlock).toContain('success: (data) => {')
|
||
expect(trashBlock).toContain('error: fail')
|
||
expect(trashBlock).toContain('finally: () => { loading.value = false }')
|
||
expect(switchBlock).toContain("if (view !== 'trash') beginLatestRequest('trash')")
|
||
})
|
||
|
||
it('starts Today requests in the same turn without awaiting summary', async () => {
|
||
const events: string[] = []
|
||
let finishTasks!: () => void
|
||
let finishOverdue!: () => void
|
||
let finishSummary!: () => void
|
||
const primary = startPrimaryWithBackground(
|
||
[
|
||
() => new Promise<void>((resolve) => { events.push('tasks:start'); finishTasks = resolve }),
|
||
() => new Promise<void>((resolve) => { events.push('overdue:start'); finishOverdue = resolve }),
|
||
],
|
||
() => new Promise<void>((resolve) => { events.push('summary:start'); finishSummary = resolve }),
|
||
).then(() => events.push('primary:done'))
|
||
|
||
expect(events).toEqual(['tasks:start', 'overdue:start', 'summary:start'])
|
||
finishTasks()
|
||
finishOverdue()
|
||
await primary
|
||
expect(events).toEqual(['tasks:start', 'overdue:start', 'summary:start', 'primary:done'])
|
||
finishSummary()
|
||
})
|
||
|
||
it('keeps Today primary completion independent from summary failure', async () => {
|
||
const primary = startPrimaryWithBackground(
|
||
[() => Promise.resolve(), () => Promise.resolve()],
|
||
() => Promise.reject(new Error('summary unavailable')),
|
||
)
|
||
await expect(primary).resolves.toEqual([undefined, undefined])
|
||
})
|
||
})
|
||
|
||
describe('habit request boundaries', () => {
|
||
it('never loads archived habits for embedded Today habits and loads them lazily on Habits', () => {
|
||
const mounted = habits.slice(habits.indexOf('onMounted(() =>'), habits.indexOf('onBeforeUnmount(() =>'))
|
||
const archiveBlock = habits.slice(habits.indexOf('async function archiveHabit'), habits.indexOf('async function restoreHabit'))
|
||
const loadBlock = habits.slice(habits.indexOf('async function loadArchivedHabits'), habits.indexOf('async function toggleArchivedHabits'))
|
||
expect(mounted).not.toContain('loadArchivedHabits()')
|
||
expect(archiveBlock).toContain("if (props.view === 'habits' && showArchivedHabits.value)")
|
||
expect(loadBlock).toContain("if (props.view !== 'habits'")
|
||
expect(habits).toContain("archiveState === 'success' ? `已归档(${archivedHabits.length})` : '已归档'")
|
||
})
|
||
|
||
it('does not render an empty archive until loading succeeds and retries after failure', () => {
|
||
const toggleBlock = habits.slice(habits.indexOf('async function toggleArchivedHabits'), habits.indexOf('function refreshHabitDay'))
|
||
expect(toggleBlock).toContain("showArchivedHabits.value && archiveState.value !== 'success'")
|
||
expect(habits).toContain('v-else-if="archiveFlags.empty"')
|
||
expect(habits).toContain("v-else-if=\"archiveState === 'error'\"")
|
||
expect(habits).toContain('@click="loadArchivedHabits">重试</button>')
|
||
})
|
||
})
|
||
|
||
describe('shared countdown cache', () => {
|
||
it('deduplicates prefetch and mount while a request is in flight', async () => {
|
||
invalidateCountdownCache()
|
||
let resolve!: (value: { items: unknown[]; archived: unknown[] }) => void
|
||
const fetcher = vi.fn(() => new Promise<{ items: unknown[]; archived: unknown[] }>((done) => { resolve = done }))
|
||
const first = loadCountdownCache(fetcher)
|
||
const second = loadCountdownCache(fetcher)
|
||
expect(fetcher).toHaveBeenCalledTimes(1)
|
||
resolve({ items: [{ id: 'one' }], archived: [] })
|
||
await expect(first).resolves.toEqual({ items: [{ id: 'one' }], archived: [] })
|
||
await expect(second).resolves.toEqual({ items: [{ id: 'one' }], archived: [] })
|
||
})
|
||
|
||
it('does not let an invalidated in-flight result overwrite a newer forced load', async () => {
|
||
invalidateCountdownCache()
|
||
const oldGeneration = getCountdownCacheGeneration()
|
||
let resolveOld!: (value: { items: unknown[]; archived: unknown[] }) => void
|
||
const oldLoad = loadCountdownCache<unknown>(() => new Promise<{ items: unknown[]; archived: unknown[] }>((resolve) => { resolveOld = resolve }))
|
||
|
||
invalidateCountdownCache()
|
||
const newGeneration = getCountdownCacheGeneration()
|
||
const fresh = { items: [{ id: 'new' }], archived: [{ id: 'new-archived' }] }
|
||
await expect(loadCountdownCache(async () => fresh, { force: true })).resolves.toEqual(fresh)
|
||
resolveOld({ items: [{ id: 'old' }], archived: [{ id: 'old-archived' }] })
|
||
const stale = await oldLoad
|
||
|
||
const componentState = { items: fresh.items, archived: fresh.archived }
|
||
if (isCountdownCacheGenerationCurrent(oldGeneration)) Object.assign(componentState, stale)
|
||
expect(isCountdownCacheGenerationCurrent(oldGeneration)).toBe(false)
|
||
expect(isCountdownCacheGenerationCurrent(newGeneration)).toBe(true)
|
||
expect(componentState).toEqual(fresh)
|
||
expect(readCountdownCache()).toEqual(fresh)
|
||
})
|
||
|
||
it('reuses fresh data and invalidates it after a write', async () => {
|
||
invalidateCountdownCache()
|
||
const fetcher = vi.fn(async () => ({ items: [{ id: 'one' }], archived: [] }))
|
||
await loadCountdownCache(fetcher, { now: 1000 })
|
||
await loadCountdownCache(fetcher, { now: 1500 })
|
||
expect(fetcher).toHaveBeenCalledTimes(1)
|
||
expect(readCountdownCache()).toEqual({ items: [{ id: 'one' }], archived: [] })
|
||
invalidateCountdownCache()
|
||
await loadCountdownCache(fetcher, { now: 1600 })
|
||
expect(fetcher).toHaveBeenCalledTimes(2)
|
||
})
|
||
})
|