fix(aiui): banner follows the selected item — reset fallback state on identity change
Operator-reported: selecting a different item in the content window left the context-surface banner showing the previous item's image. Root cause: detail views are reused, not remounted, and useBannerFallback kept primaryIndex/ stage/apiUrl alive across the prop change — once stage hit 'api' or 'done' it never re-evaluated. Reset is keyed on title + the primary URL set, with a generation guard so an in-flight fetch for the old item cannot stamp its artwork onto the new one. Heals Film/TVSeries/Book detail at once; 3 regression tests pin it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6ac0ebbf0a
commit
86129d5da2
@@ -0,0 +1,92 @@
|
||||
// Regression pin for the stale-banner bug (operator-reported 2026-08-04):
|
||||
// detail views are REUSED, not remounted, when a different item is selected in
|
||||
// the content window. The composable's stage/apiUrl/primaryIndex used to
|
||||
// survive that change, so the context-surface banner kept showing the previous
|
||||
// item's artwork forever. These tests pin the identity-keyed reset and the
|
||||
// stale-fetch generation guard.
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { useBannerFallback } from '../useBannerFallback'
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (v: T) => void
|
||||
const promise = new Promise<T>((r) => { resolve = r })
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('useBannerFallback', () => {
|
||||
it('resets to the new item\'s primary URL after the previous item resolved via API', async () => {
|
||||
const title = ref('Item A')
|
||||
const primaries = ref<(string | null)[]>([])
|
||||
const apiFetch = vi.fn().mockResolvedValue({ posterUrl: 'https://api/a.jpg', backdropUrl: null })
|
||||
|
||||
const { bannerSrc } = useBannerFallback({
|
||||
primaryUrls: () => primaries.value,
|
||||
apiFetch,
|
||||
title: () => title.value,
|
||||
})
|
||||
|
||||
// Item A has no primaries — resolves through the API path.
|
||||
await nextTick()
|
||||
await vi.waitFor(() => expect(bannerSrc.value).toBe('https://api/a.jpg'))
|
||||
|
||||
// Select item B, which has its own primary URL. Before the fix, stage was
|
||||
// stuck at 'api' and bannerSrc kept returning A's artwork.
|
||||
title.value = 'Item B'
|
||||
primaries.value = ['https://cdn/b-poster.jpg']
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(bannerSrc.value).toBe('https://cdn/b-poster.jpg')
|
||||
})
|
||||
|
||||
it('discards a stale API resolution that lands after the item changed', async () => {
|
||||
const title = ref('Item A')
|
||||
const primaries = ref<(string | null)[]>([])
|
||||
const slow = deferred<{ posterUrl: string | null; backdropUrl: string | null }>()
|
||||
const apiFetch = vi.fn().mockReturnValue(slow.promise)
|
||||
|
||||
const { bannerSrc } = useBannerFallback({
|
||||
primaryUrls: () => primaries.value,
|
||||
apiFetch,
|
||||
title: () => title.value,
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
// Item changes while A's fetch is still in flight.
|
||||
title.value = 'Item B'
|
||||
primaries.value = ['https://cdn/b-poster.jpg']
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
expect(bannerSrc.value).toBe('https://cdn/b-poster.jpg')
|
||||
|
||||
// A's fetch finally resolves — it must NOT stamp A's artwork over B.
|
||||
slow.resolve({ posterUrl: 'https://api/a-late.jpg', backdropUrl: null })
|
||||
await slow.promise
|
||||
await nextTick()
|
||||
|
||||
expect(bannerSrc.value).toBe('https://cdn/b-poster.jpg')
|
||||
})
|
||||
|
||||
it('re-runs the no-primary API kickoff for the new item after a reset', async () => {
|
||||
const title = ref('Item A')
|
||||
const primaries = ref<(string | null)[]>(['https://cdn/a.jpg'])
|
||||
const apiFetch = vi.fn().mockResolvedValue({ posterUrl: 'https://api/b.jpg', backdropUrl: null })
|
||||
|
||||
const { bannerSrc } = useBannerFallback({
|
||||
primaryUrls: () => primaries.value,
|
||||
apiFetch,
|
||||
title: () => title.value,
|
||||
})
|
||||
expect(bannerSrc.value).toBe('https://cdn/a.jpg')
|
||||
expect(apiFetch).not.toHaveBeenCalled()
|
||||
|
||||
// New item with NO primaries — the kickoff must fire again post-reset.
|
||||
title.value = 'Item B'
|
||||
primaries.value = []
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
await vi.waitFor(() => expect(bannerSrc.value).toBe('https://api/b.jpg'))
|
||||
expect(apiFetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
Binary file not shown.
Reference in New Issue
Block a user