Files

271 lines
9.3 KiB
TypeScript

import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { VisibilityObserver } from '../../src/components-lib/visibility-observer';
import {
callIntersectionHandler,
callVisibilityHandler,
createParent,
getMockIntersectionObserver,
IntersectionObserverMock,
} from '../test-utils';
// @vitest-environment jsdom
describe('VisibilityObserver', () => {
beforeAll(() => {
vi.stubGlobal('IntersectionObserver', IntersectionObserverMock);
vi.spyOn(global.document, 'addEventListener');
});
afterAll(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
beforeEach(() => {
vi.clearAllMocks();
// Reset document.visibilityState so each test starts with the tab
// visible and is not affected by leftover state from a prior
// `callVisibilityHandler(false)`.
Object.defineProperty(document, 'visibilityState', {
value: 'visible',
writable: true,
configurable: true,
});
});
describe('intersection', () => {
it('should treat the first callback as baseline and not emit', async () => {
const onChange = vi.fn();
const observer = new VisibilityObserver(onChange);
observer.setRoot(createParent());
await callIntersectionHandler(false);
expect(onChange).not.toHaveBeenCalled();
});
it('should emit true when the root scrolls into view', async () => {
const onChange = vi.fn();
const observer = new VisibilityObserver(onChange);
observer.setRoot(createParent());
await callIntersectionHandler(false);
await callIntersectionHandler(true);
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith(true);
});
it('should emit false when the root scrolls out of view', async () => {
const onChange = vi.fn();
const observer = new VisibilityObserver(onChange);
observer.setRoot(createParent());
await callIntersectionHandler(true);
await callIntersectionHandler(false);
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith(false);
});
it('should not emit when intersection state is unchanged', async () => {
const onChange = vi.fn();
const observer = new VisibilityObserver(onChange);
observer.setRoot(createParent());
await callIntersectionHandler(true);
await callIntersectionHandler(true);
expect(onChange).not.toHaveBeenCalled();
});
it('should reset the baseline when setRoot accepts a new element', async () => {
// Without the reset, the first callback for the new root would be
// compared against the previous root's last-known intersection state and
// could emit a spurious change.
const onChange = vi.fn();
const observer = new VisibilityObserver(onChange);
observer.setRoot(createParent());
// Establish a non-null intersection state on the first root.
await callIntersectionHandler(false);
await callIntersectionHandler(true);
expect(onChange).toHaveBeenCalledTimes(1);
onChange.mockClear();
observer.setRoot(createParent());
// First callback after the new setRoot is the new baseline -- no emit.
await callIntersectionHandler(false);
expect(onChange).not.toHaveBeenCalled();
// Subsequent transition does emit.
await callIntersectionHandler(true);
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith(true);
});
});
describe('document visibility', () => {
it('should emit false when the tab becomes hidden while the element is intersecting', async () => {
const onChange = vi.fn();
const observer = new VisibilityObserver(onChange);
observer.setRoot(createParent());
await callIntersectionHandler(true); // baseline: visible=true
await callVisibilityHandler(false);
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith(false);
});
it('should emit true when the tab becomes visible while the element is intersecting', async () => {
const onChange = vi.fn();
const observer = new VisibilityObserver(onChange);
observer.setRoot(createParent());
await callIntersectionHandler(true); // baseline: visible=true
await callVisibilityHandler(false); // visible -> hidden
onChange.mockClear();
await callVisibilityHandler(true);
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith(true);
});
it('should not emit on tab visibility changes while the element is not intersecting', async () => {
// Regression: live.preload renders the live element while the user is
// on gallery/viewer, where it is hidden via display:none
// (intersecting=false). Without this gate, document.visibilitychange
// would emit visible=true on tab focus, making microphone
// auto_unmute: ['visible'] open the mic from a hidden view.
const onChange = vi.fn();
const observer = new VisibilityObserver(onChange);
observer.setRoot(createParent());
await callIntersectionHandler(false); // baseline: visible=false
await callVisibilityHandler(false);
await callVisibilityHandler(true);
expect(onChange).not.toHaveBeenCalled();
});
it('should emit nothing for tab events received before any intersection callback', async () => {
const onChange = vi.fn();
const observer = new VisibilityObserver(onChange);
observer.setRoot(createParent());
await callVisibilityHandler(false);
await callVisibilityHandler(true);
expect(onChange).not.toHaveBeenCalled();
});
});
describe('combining tab and intersection signals', () => {
it('should emit true only when both signals are visible', async () => {
const onChange = vi.fn();
const observer = new VisibilityObserver(onChange);
observer.setRoot(createParent());
// baseline: visible=false
await callIntersectionHandler(false);
// visible=false (still)
await callVisibilityHandler(false);
expect(onChange).not.toHaveBeenCalled();
// Tab visible but element not intersecting -> still false.
await callVisibilityHandler(true);
expect(onChange).not.toHaveBeenCalled();
// Element starts intersecting AND tab visible -> emit true.
await callIntersectionHandler(true);
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith(true);
});
});
describe('lifecycle', () => {
it('should be idempotent on setRoot for the same element', () => {
const observer = new VisibilityObserver(vi.fn());
const parent = createParent();
observer.setRoot(parent);
const intersectionObserver = getMockIntersectionObserver();
expect(intersectionObserver?.observe).toHaveBeenCalledTimes(1);
expect(intersectionObserver?.disconnect).toHaveBeenCalledTimes(1);
observer.setRoot(parent);
// Same root: no re-observe, no re-disconnect.
expect(intersectionObserver?.observe).toHaveBeenCalledTimes(1);
expect(intersectionObserver?.disconnect).toHaveBeenCalledTimes(1);
});
it('should disconnect the intersection observer and remove the visibility listener on destroy', () => {
const removeEventListenerSpy = vi.spyOn(global.document, 'removeEventListener');
const observer = new VisibilityObserver(vi.fn());
observer.setRoot(createParent());
const intersectionObserver = getMockIntersectionObserver();
observer.destroy();
expect(intersectionObserver?.disconnect).toHaveBeenCalled();
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'visibilitychange',
expect.any(Function),
);
});
});
describe('emitInitial', () => {
it('should emit the initial visible value on the baseline callback', async () => {
const onChange = vi.fn();
const observer = new VisibilityObserver(onChange, { emitInitial: true });
observer.setRoot(createParent());
await callIntersectionHandler(true);
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith(true);
});
it('should emit the initial hidden value on the baseline callback', async () => {
const onChange = vi.fn();
const observer = new VisibilityObserver(onChange, { emitInitial: true });
observer.setRoot(createParent());
await callIntersectionHandler(false);
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith(false);
});
it('should not re-emit an unchanged state after the initial emit', async () => {
const onChange = vi.fn();
const observer = new VisibilityObserver(onChange, { emitInitial: true });
observer.setRoot(createParent());
await callIntersectionHandler(true);
await callIntersectionHandler(true);
expect(onChange).toHaveBeenCalledTimes(1);
});
it('should still emit subsequent transitions after the initial emit', async () => {
const onChange = vi.fn();
const observer = new VisibilityObserver(onChange, { emitInitial: true });
observer.setRoot(createParent());
await callIntersectionHandler(true);
await callIntersectionHandler(false);
expect(onChange).toHaveBeenNthCalledWith(1, true);
expect(onChange).toHaveBeenNthCalledWith(2, false);
});
});
});