Files
minstrel/web/src/lib/components/InfiniteScrollSentinel.test.ts
T
bvandeusen 45793779df feat(web): infinite scroll on /library/artists and /library/albums
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>
2026-05-01 22:30:24 -04:00

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);
});
});