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((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((resolve) => { events.push('refresh'); releaseRefresh = resolve }), ) const first = reconciler.run( () => new Promise((resolve) => { resolveFirst = resolve }), () => events.push('success:first'), ) const second = reconciler.run( () => new Promise((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((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((resolve) => { resolveFirstRefresh = resolve }) } events.push('refresh:second-done') return Promise.resolve() }, ) const first = reconciler.run( () => new Promise((resolve) => { resolveFirstMutation = resolve }), () => events.push('success:first'), ) const second = reconciler.run( () => new Promise((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((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((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((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((_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((resolve) => { events.push('tasks:start'); finishTasks = resolve }), () => new Promise((resolve) => { events.push('overdue:start'); finishOverdue = resolve }), ], () => new Promise((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">重试') }) }) 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(() => 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) }) })