fix(showcase): buffered producer/consumer for a steady cascade
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 13s
CI / frontend-build (push) Successful in 20s
CI / intimp (push) Successful in 3m32s
CI / intapi (push) Successful in 7m35s
CI / intcore (push) Successful in 9m8s

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:
2026-06-04 00:35:25 -04:00
parent 6d630d13d6
commit 76d8ad42a8
2 changed files with 173 additions and 88 deletions
+72
View File
@@ -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)
})
})