fix(showcase): buffered producer/consumer for a steady cascade
The cascade "burped" — chunks appeared unevenly — because the old pipeline coupled display to fetch timing: it trickled each batch right after its fetch and assumed the next round-trip would land inside the ~240ms trickle window. When a fetch ran long (TABLESAMPLE hits random, sometimes-cold blocks; RTT jitter) the animation starved, then a clump burst in. Decouple the two: - Producer (_fill) races ahead fetching batches into a buffer up to a target depth, refilling when it dips below BUFFER_MIN. - Consumer (_drain) reveals one item every CADENCE_MS regardless of when fetches land; it only waits if the buffer genuinely starves. A small PRIME buffer precedes the drain so it doesn't starve at the front; the buffer (BUFFER_MIN×CADENCE runway) absorbs per-fetch jitter so images appear at an even pace. Public store API (loadInitial/shuffle/fetchPage/ images/loading/hasMore/isEmpty) unchanged — ShowcaseView/MasonryGrid need no change. Test (fake timers): fire-order + dedup, one-item-per-cadence rate limit, empty-library flag. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useShowcaseStore } from '../src/stores/showcase.js'
|
||||
|
||||
let nextId
|
||||
|
||||
function stubShowcase({ empty = false } = {}) {
|
||||
globalThis.fetch = vi.fn(async () => {
|
||||
const images = empty
|
||||
? []
|
||||
: Array.from({ length: 3 }, () => ({
|
||||
id: nextId++, sha256: 's', mime: 'image/jpeg',
|
||||
width: 1, height: 1, thumbnail_url: '/t',
|
||||
}))
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: '200',
|
||||
text: async () => JSON.stringify({ images }),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('showcase store: buffered cascade', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
nextId = 1
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('drains buffered items in fire-order at a fixed cadence, no dupes', async () => {
|
||||
stubShowcase()
|
||||
const s = useShowcaseStore()
|
||||
const p = s.loadInitial()
|
||||
// Past INITIAL_COUNT (60) × CADENCE (80ms) plus fetch microtasks.
|
||||
await vi.advanceTimersByTimeAsync(6000)
|
||||
await p
|
||||
expect(s.images.length).toBe(60)
|
||||
const ids = s.images.map((i) => i.id)
|
||||
expect(new Set(ids).size).toBe(60) // dedup held
|
||||
expect(ids).toEqual([...ids].sort((a, b) => a - b)) // shown in fire order
|
||||
})
|
||||
|
||||
it('reveals at most one item per cadence tick (decoupled from fetch)', async () => {
|
||||
stubShowcase()
|
||||
const s = useShowcaseStore()
|
||||
const p = s.loadInitial()
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
const early = s.images.length
|
||||
// Even though fetches resolve instantly, the consumer is rate-limited by
|
||||
// the cadence — a 1s window must not dump the whole buffer at once.
|
||||
expect(early).toBeGreaterThan(0)
|
||||
expect(early).toBeLessThan(60)
|
||||
await vi.advanceTimersByTimeAsync(6000)
|
||||
await p
|
||||
expect(s.images.length).toBe(60)
|
||||
})
|
||||
|
||||
it('flags an empty library without starting the cascade', async () => {
|
||||
stubShowcase({ empty: true })
|
||||
const s = useShowcaseStore()
|
||||
const p = s.loadInitial()
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
await p
|
||||
expect(s.images.length).toBe(0)
|
||||
expect(s.isEmpty).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user