76d8ad42a8
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>
73 lines
2.2 KiB
JavaScript
73 lines
2.2 KiB
JavaScript
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)
|
||
})
|
||
})
|