Files
dodo/frontend/src/lib/performance.test.ts
T
bboysoul 8b475e4df5
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 3m25s
feat: animate completed items out
2026-09-11 08:56:51 +08:00

335 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
})
})