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 (
{loading ? 'loading' : error ? 'error' : 'ready'} {items.map((item) => item.title).join('|')}
) } 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() 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() 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() 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() 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() 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() await act(async () => callback([{ isIntersecting: true }])) await waitFor(() => expect(screen.getByTestId('items').textContent).toBe('Persisted rail')) first.unmount() render() 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() await act(async () => callbacks[0]([{ isIntersecting: true }])) await waitFor(() => expect(screen.getByTestId('items').textContent).toBe('Artwork A')) view.rerender() await act(async () => callbacks[1]([{ isIntersecting: true }])) await waitFor(() => expect(screen.getByTestId('items').textContent).toBe('Artwork B')) view.rerender() 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(<>) 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() render() 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() const second = render() 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() await act(async () => callback([{ isIntersecting: true }])) first.unmount() await act(async () => {}) render() 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() await act(async () => callback([{ isIntersecting: true }])) await waitFor(() => expect(screen.getByTestId('state').textContent).toBe('error')) first.unmount() render() 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() await act(async () => callback([{ isIntersecting: true }])) view.rerender() 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) }) })