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