3a4270e6be
The buffered cascade revealed tiles on an 80ms timer regardless of image load, so the flip-in animation played on a gray placeholder and the thumbnail popped in afterward. Worse, MasonryGrid ALSO applied a per-index animation-delay (index×70ms) that compounded on top of the insert cadence, so the cascade visibly dragged and desynced as it grew. Now the producer preloads each queued thumbnail (decode pipelined ahead) and the consumer awaits that decode before pushing the item — every tile animates in fully loaded, strictly one at a time. Drop the compounding CSS stagger; the store's one-item-at-a-time push is the sole pacer, so each tile animates the instant it mounts. New utils/preloadImage.js (load+decode+timeout gate). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
98 lines
3.2 KiB
JavaScript
98 lines
3.2 KiB
JavaScript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||
import { setActivePinia, createPinia } from 'pinia'
|
||
|
||
// Mock the decode-gate so the cascade is paced purely by the store's cadence
|
||
// in tests (no real <img> load events in happy-dom). Individual tests can
|
||
// override per-call to exercise the gating itself.
|
||
vi.mock('../src/utils/preloadImage.js', () => ({
|
||
preloadImage: vi.fn(() => Promise.resolve()),
|
||
}))
|
||
import { preloadImage } from '../src/utils/preloadImage.js'
|
||
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
|
||
preloadImage.mockReset()
|
||
preloadImage.mockImplementation(() => Promise.resolve())
|
||
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('does not reveal a tile until its image has decoded', async () => {
|
||
stubShowcase()
|
||
let release
|
||
// The first tile's preload hangs until we release it; all others resolve.
|
||
preloadImage.mockImplementationOnce(() => new Promise((r) => { release = r }))
|
||
const s = useShowcaseStore()
|
||
const p = s.loadInitial()
|
||
await vi.advanceTimersByTimeAsync(1000)
|
||
expect(s.images.length).toBe(0) // gated on the un-decoded first image
|
||
release()
|
||
await vi.advanceTimersByTimeAsync(6000)
|
||
await p
|
||
expect(s.images.length).toBe(60) // cascade proceeds once it decodes
|
||
})
|
||
|
||
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)
|
||
})
|
||
})
|