45793779df
Replaces the explicit 'Load more' button on both library list pages with an IntersectionObserver-based sentinel that triggers fetchNextPage automatically as the operator scrolls near the bottom (300px rootMargin). New InfiniteScrollSentinel component: - Stays disabled while a fetch is in flight (prevents repeat firing on tall pages or fast scrolls). - Tears down its observer when hasNextPage flips false. - Defensive against environments without IntersectionObserver (jsdom); tests provide a synchronous mock. Also fixes a pre-existing test setup gap on the albums page test: AlbumCard renders LikeButton which calls useQueryClient(), and the page.test.ts wasn't wrapped in a QueryClientProvider — added the same useQueryClient mock the AlbumCard.test.ts already uses. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
79 lines
2.6 KiB
TypeScript
79 lines
2.6 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
|
import { render } from '@testing-library/svelte';
|
|
import InfiniteScrollSentinel from './InfiniteScrollSentinel.svelte';
|
|
|
|
// jsdom doesn't ship IntersectionObserver. We install a minimal mock that
|
|
// captures the registered callback so tests can drive intersection events
|
|
// synchronously.
|
|
type IOInstance = {
|
|
observe: ReturnType<typeof vi.fn>;
|
|
disconnect: ReturnType<typeof vi.fn>;
|
|
fire: (isIntersecting: boolean) => void;
|
|
};
|
|
const created: IOInstance[] = [];
|
|
|
|
beforeEach(() => {
|
|
created.length = 0;
|
|
(globalThis as unknown as { IntersectionObserver: unknown }).IntersectionObserver =
|
|
class MockIO {
|
|
private cb: IntersectionObserverCallback;
|
|
observe = vi.fn();
|
|
disconnect = vi.fn();
|
|
constructor(cb: IntersectionObserverCallback) {
|
|
this.cb = cb;
|
|
created.push({
|
|
observe: this.observe,
|
|
disconnect: this.disconnect,
|
|
fire: (isIntersecting: boolean) => {
|
|
this.cb(
|
|
[{ isIntersecting } as unknown as IntersectionObserverEntry],
|
|
this as unknown as IntersectionObserver
|
|
);
|
|
}
|
|
});
|
|
}
|
|
};
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('InfiniteScrollSentinel', () => {
|
|
test('observes the sentinel when enabled', () => {
|
|
const onIntersect = vi.fn();
|
|
render(InfiniteScrollSentinel, { props: { onIntersect, enabled: true } });
|
|
expect(created).toHaveLength(1);
|
|
expect(created[0].observe).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test('does not create an observer when enabled is false', () => {
|
|
const onIntersect = vi.fn();
|
|
render(InfiniteScrollSentinel, { props: { onIntersect, enabled: false } });
|
|
expect(created).toHaveLength(0);
|
|
});
|
|
|
|
test('fires onIntersect when the sentinel enters view', () => {
|
|
const onIntersect = vi.fn();
|
|
render(InfiniteScrollSentinel, { props: { onIntersect, enabled: true } });
|
|
created[0].fire(true);
|
|
expect(onIntersect).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test('does not fire onIntersect when the entry is not intersecting', () => {
|
|
const onIntersect = vi.fn();
|
|
render(InfiniteScrollSentinel, { props: { onIntersect, enabled: true } });
|
|
created[0].fire(false);
|
|
expect(onIntersect).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('disconnects the observer when the component unmounts', () => {
|
|
const onIntersect = vi.fn();
|
|
const { unmount } = render(InfiniteScrollSentinel, {
|
|
props: { onIntersect, enabled: true }
|
|
});
|
|
unmount();
|
|
expect(created[0].disconnect).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|