Files
SkinbaseNova/resources/js/lib/useDeferredSimilarAi.test.jsx
klevze 1d350c9bca Defer similar-AI recommendations until the rail is near view.
ArtworkPage already imports this hook; ship the implementation so similar-AI work stays off the initial render.
2026-08-29 12:25:49 +02:00

289 lines
11 KiB
React

import React, { useRef } from 'react'
import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
import {
__resetDeferredSimilarAiCache,
useDeferredSimilarAi,
} from './useDeferredSimilarAi'
function Harness({ artworkId, instance = '' }) {
const anchorRef = useRef(null)
const { items, loading, error } = useDeferredSimilarAi(artworkId, anchorRef)
const suffix = instance ? `-${instance}` : ''
return (
<div ref={anchorRef}>
<output data-testid={`state${suffix}`}>{loading ? 'loading' : error ? 'error' : 'ready'}</output>
<output data-testid={`items${suffix}`}>{items.map((item) => item.title).join('|')}</output>
</div>
)
}
function response(data, ok = true) {
return { ok, json: async () => ({ data }) }
}
describe('useDeferredSimilarAi', () => {
afterEach(() => {
cleanup()
vi.restoreAllMocks()
delete window.IntersectionObserver
delete window.requestIdleCallback
delete window.cancelIdleCallback
__resetDeferredSimilarAiCache()
})
it('does not fetch during initial render', async () => {
global.fetch = vi.fn()
window.IntersectionObserver = vi.fn(() => ({ observe: vi.fn(), disconnect: vi.fn() }))
render(<Harness artworkId={10} />)
await act(async () => {})
expect(global.fetch).not.toHaveBeenCalled()
})
it('loads once when the section approaches the viewport', async () => {
let callback
window.IntersectionObserver = vi.fn((next) => {
callback = next
return { observe: vi.fn(), disconnect: vi.fn() }
})
global.fetch = vi.fn().mockResolvedValue(response([{ title: 'Near rail' }]))
render(<Harness artworkId={11} />)
await act(async () => callback([{ isIntersecting: true }]))
await waitFor(() => expect(screen.getByTestId('items').textContent).toBe('Near rail'))
expect(global.fetch).toHaveBeenCalledTimes(1)
expect(window.IntersectionObserver).toHaveBeenCalledWith(expect.any(Function), { rootMargin: '800px 0px' })
expect(global.fetch).toHaveBeenCalledWith('/api/art/11/similar-ai', expect.objectContaining({ signal: expect.any(AbortSignal) }))
})
it('does not use the idle fallback when observation is established', async () => {
const observe = vi.fn()
window.IntersectionObserver = vi.fn(() => ({ observe, disconnect: vi.fn() }))
window.requestIdleCallback = vi.fn()
window.cancelIdleCallback = vi.fn()
global.fetch = vi.fn()
vi.useFakeTimers()
try {
render(<Harness artworkId={12} />)
await act(async () => vi.advanceTimersByTime(3100))
expect(observe).toHaveBeenCalledTimes(1)
expect(window.requestIdleCallback).not.toHaveBeenCalled()
expect(global.fetch).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('uses the idle fallback once when IntersectionObserver is unavailable', async () => {
let idleCallback
window.requestIdleCallback = vi.fn((callback) => { idleCallback = callback; return 1 })
window.cancelIdleCallback = vi.fn()
global.fetch = vi.fn().mockResolvedValue(response([{ title: 'Idle rail' }]))
render(<Harness artworkId={12} />)
expect(global.fetch).not.toHaveBeenCalled()
await act(async () => idleCallback())
await act(async () => idleCallback())
await waitFor(() => expect(screen.getByTestId('items').textContent).toBe('Idle rail'))
expect(global.fetch).toHaveBeenCalledTimes(1)
})
it('ignores repeated observer callbacks and preserves error behavior', async () => {
let callback
window.IntersectionObserver = vi.fn((next) => {
callback = next
return { observe: vi.fn(), disconnect: vi.fn() }
})
global.fetch = vi.fn().mockResolvedValue({ ok: false, json: async () => ({}) })
render(<Harness artworkId={13} />)
await act(async () => {
callback([{ isIntersecting: true }])
callback([{ isIntersecting: true }])
})
await waitFor(() => expect(screen.getByTestId('state').textContent).toBe('error'))
expect(global.fetch).toHaveBeenCalledTimes(1)
})
it('does not request the same artwork again after unmount and remount', async () => {
let callback
window.IntersectionObserver = vi.fn((next) => {
callback = next
return { observe: vi.fn(), disconnect: vi.fn() }
})
global.fetch = vi.fn().mockResolvedValue(response([{ title: 'Persisted rail' }]))
const first = render(<Harness artworkId={16} />)
await act(async () => callback([{ isIntersecting: true }]))
await waitFor(() => expect(screen.getByTestId('items').textContent).toBe('Persisted rail'))
first.unmount()
render(<Harness artworkId={16} />)
expect(screen.getByTestId('items').textContent).toBe('Persisted rail')
expect(global.fetch).toHaveBeenCalledTimes(1)
})
it('reuses artwork A when navigating A to B and back to A', async () => {
const callbacks = []
window.IntersectionObserver = vi.fn((next) => {
callbacks.push(next)
return { observe: vi.fn(), disconnect: vi.fn() }
})
global.fetch = vi.fn()
.mockResolvedValueOnce(response([{ title: 'Artwork A' }]))
.mockResolvedValueOnce(response([{ title: 'Artwork B' }]))
const view = render(<Harness artworkId={17} />)
await act(async () => callbacks[0]([{ isIntersecting: true }]))
await waitFor(() => expect(screen.getByTestId('items').textContent).toBe('Artwork A'))
view.rerender(<Harness artworkId={18} />)
await act(async () => callbacks[1]([{ isIntersecting: true }]))
await waitFor(() => expect(screen.getByTestId('items').textContent).toBe('Artwork B'))
view.rerender(<Harness artworkId={17} />)
expect(screen.getByTestId('items').textContent).toBe('Artwork A')
expect(global.fetch).toHaveBeenCalledTimes(2)
})
it('shares one in-flight request between hook instances', async () => {
const callbacks = []
window.IntersectionObserver = vi.fn((next) => {
callbacks.push(next)
return { observe: vi.fn(), disconnect: vi.fn() }
})
global.fetch = vi.fn().mockResolvedValue(response([{ title: 'Shared rail' }]))
render(<><Harness artworkId={19} /><Harness artworkId={19} /></>)
await act(async () => {
callbacks[0]([{ isIntersecting: true }])
callbacks[1]([{ isIntersecting: true }])
})
await waitFor(() => expect(global.fetch).toHaveBeenCalledTimes(1))
})
it('keeps the shared request alive when one consumer unmounts', async () => {
const callbacks = []
let resolveRequest
window.IntersectionObserver = vi.fn((next) => {
callbacks.push(next)
return { observe: vi.fn(), disconnect: vi.fn() }
})
global.fetch = vi.fn().mockReturnValue(new Promise((resolve) => { resolveRequest = resolve }))
const first = render(<Harness artworkId={22} instance="a" />)
render(<Harness artworkId={22} instance="b" />)
await act(async () => {
callbacks[0]([{ isIntersecting: true }])
callbacks[1]([{ isIntersecting: true }])
})
expect(global.fetch).toHaveBeenCalledTimes(1)
first.unmount()
expect(global.fetch.mock.calls[0][1].signal.aborted).toBe(false)
await act(async () => resolveRequest(response([{ title: 'Surviving rail' }])))
await waitFor(() => expect(screen.getByTestId('items-b').textContent).toBe('Surviving rail'))
expect(global.fetch).toHaveBeenCalledTimes(1)
})
it('aborts the shared request after the final consumer unmounts', async () => {
const callbacks = []
let requestOptions
window.IntersectionObserver = vi.fn((next) => {
callbacks.push(next)
return { observe: vi.fn(), disconnect: vi.fn() }
})
global.fetch = vi.fn().mockImplementation((url, options) => {
requestOptions = options
return new Promise(() => {})
})
const first = render(<Harness artworkId={23} instance="a" />)
const second = render(<Harness artworkId={23} instance="b" />)
await act(async () => {
callbacks[0]([{ isIntersecting: true }])
callbacks[1]([{ isIntersecting: true }])
})
expect(global.fetch).toHaveBeenCalledTimes(1)
first.unmount()
expect(requestOptions.signal.aborted).toBe(false)
second.unmount()
expect(requestOptions.signal.aborted).toBe(true)
})
it('allows an aborted request to be fetched again later', async () => {
let callback
window.IntersectionObserver = vi.fn((next) => {
callback = next
return { observe: vi.fn(), disconnect: vi.fn() }
})
global.fetch = vi.fn()
.mockImplementationOnce((url, { signal }) => new Promise((resolve, reject) => {
signal.addEventListener('abort', () => reject(Object.assign(new Error('aborted'), { name: 'AbortError' })))
}))
.mockResolvedValueOnce(response([{ title: 'Retried rail' }]))
const first = render(<Harness artworkId={20} />)
await act(async () => callback([{ isIntersecting: true }]))
first.unmount()
await act(async () => {})
render(<Harness artworkId={20} />)
await act(async () => callback([{ isIntersecting: true }]))
await waitFor(() => expect(screen.getByTestId('items').textContent).toBe('Retried rail'))
expect(global.fetch).toHaveBeenCalledTimes(2)
})
it('allows a failed request to be retried after remount', async () => {
let callback
window.IntersectionObserver = vi.fn((next) => {
callback = next
return { observe: vi.fn(), disconnect: vi.fn() }
})
global.fetch = vi.fn()
.mockResolvedValueOnce({ ok: false, json: async () => ({}) })
.mockResolvedValueOnce(response([{ title: 'Recovered rail' }]))
const first = render(<Harness artworkId={21} />)
await act(async () => callback([{ isIntersecting: true }]))
await waitFor(() => expect(screen.getByTestId('state').textContent).toBe('error'))
first.unmount()
render(<Harness artworkId={21} />)
await act(async () => callback([{ isIntersecting: true }]))
await waitFor(() => expect(screen.getByTestId('items').textContent).toBe('Recovered rail'))
expect(global.fetch).toHaveBeenCalledTimes(2)
})
it('resets for a new artwork ID and ignores the stale response', async () => {
let callback
let resolveOld
window.IntersectionObserver = vi.fn((next) => {
callback = next
return { observe: vi.fn(), disconnect: vi.fn() }
})
global.fetch = vi.fn()
.mockReturnValueOnce(new Promise((resolve) => { resolveOld = resolve }))
.mockResolvedValueOnce(response([{ title: 'New rail' }]))
const view = render(<Harness artworkId={14} />)
await act(async () => callback([{ isIntersecting: true }]))
view.rerender(<Harness artworkId={15} />)
await act(async () => callback([{ isIntersecting: true }]))
await waitFor(() => expect(screen.getByTestId('items').textContent).toBe('New rail'))
await act(async () => resolveOld(response([{ title: 'Old rail' }])))
expect(screen.getByTestId('items').textContent).toBe('New rail')
expect(global.fetch).toHaveBeenCalledTimes(2)
})
})