Files

402 lines
12 KiB
TypeScript

import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import { LazyLoadController } from '../../src/components-lib/lazy-load-controller';
import type { LazyUnloadCondition } from '../../src/config/schema/common/media-actions';
import {
callIntersectionHandler,
callVisibilityHandler,
createLitElement,
getMockIntersectionObserver,
IntersectionObserverMock,
} from '../test-utils';
// @vitest-environment jsdom
describe('LazyLoadController', () => {
beforeAll(() => {
vi.stubGlobal('IntersectionObserver', IntersectionObserverMock);
});
afterAll(() => {
vi.unstubAllGlobals();
});
beforeEach(() => {
vi.spyOn(global.document, 'addEventListener');
vi.spyOn(global.document, 'removeEventListener');
Object.defineProperty(document, 'visibilityState', {
value: 'visible',
writable: true,
configurable: true,
});
});
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
});
it('should be unloaded by default', () => {
const controller = new LazyLoadController(createLitElement());
expect(controller.isLoaded()).toBe(false);
});
it('should not be loaded by default when lazy load is set to true', () => {
const controller = new LazyLoadController(createLitElement());
controller.setConfiguration({ lazyLoad: true });
expect(controller.isLoaded()).toBe(false);
});
it('should add controller to host', () => {
const host = createLitElement();
const controller = new LazyLoadController(host);
expect(host.addController).toHaveBeenCalledWith(controller);
});
it('should remove controller from host', () => {
const host = createLitElement();
const controller = new LazyLoadController(host);
controller.removeController();
expect(host.removeController).toHaveBeenCalledWith(controller);
});
it('should remove handlers and listeners on destroy', () => {
const controller = new LazyLoadController(createLitElement());
controller.setConfiguration({
lazyLoad: true,
lazyUnloadConditions: ['unselected', 'hidden'],
});
controller.hostConnected();
const listener = vi.fn();
controller.addListener(listener);
controller.destroy();
expect(getMockIntersectionObserver()?.disconnect).toHaveBeenCalled();
expect(global.document.removeEventListener).toHaveBeenCalledWith(
'visibilitychange',
expect.anything(),
);
expect(controller.isLoaded()).toBe(false);
callVisibilityHandler(true);
callIntersectionHandler(true);
expect(listener).not.toHaveBeenCalled();
});
describe('should set configuration', () => {
it('should set loaded if lazy loading set to false', () => {
const listener = vi.fn();
const controller = new LazyLoadController(createLitElement());
controller.addListener(listener);
expect(controller.isLoaded()).toBe(false);
expect(listener).not.toHaveBeenCalled();
controller.setConfiguration({ lazyLoad: false });
expect(controller.isLoaded()).toBe(true);
expect(listener).toHaveBeenCalled();
});
it('should re-evaluate unload when conditions change while loaded', () => {
const controller = new LazyLoadController(createLitElement());
controller.setConfiguration({ lazyLoad: true });
controller.hostConnected();
callIntersectionHandler(true);
callVisibilityHandler(true);
expect(controller.isLoaded()).toBe(true);
callIntersectionHandler(false);
expect(controller.isLoaded()).toBe(true);
controller.setConfiguration({
lazyLoad: true,
lazyUnloadConditions: ['unselected'],
});
expect(controller.isLoaded()).toBe(false);
});
it('should unload via `hidden` immediately when toggled on a hidden tab', () => {
const controller = new LazyLoadController(createLitElement());
controller.setConfiguration({ lazyLoad: false });
controller.hostConnected();
expect(controller.isLoaded()).toBe(true);
callVisibilityHandler(false);
expect(controller.isLoaded()).toBe(true);
controller.setConfiguration({
lazyLoad: true,
lazyUnloadConditions: ['hidden'],
});
expect(controller.isLoaded()).toBe(false);
});
it('should reset omitted fields to their defaults', () => {
const controller = new LazyLoadController(createLitElement());
controller.setConfiguration({
lazyLoad: true,
lazyUnloadConditions: ['unselected'],
forceSelected: true,
});
controller.hostConnected();
callVisibilityHandler(true);
expect(controller.isLoaded()).toBe(true);
// Each call is a complete snapshot: omitting `forceSelected` resets it
// to false, and omitting `lazyUnloadConditions` resets it to []. With
// forceSelected reset and intersection then dropping, no `unselected`
// condition remains to act on it, so the host stays loaded.
controller.setConfiguration({ lazyLoad: true });
callIntersectionHandler(false);
expect(controller.isLoaded()).toBe(true);
// Re-introducing `unselected` while not intersecting (and with
// forceSelected still defaulted to false) must now unload, proving
// forceSelected was actually reset by the previous call.
controller.setConfiguration({
lazyLoad: true,
lazyUnloadConditions: ['unselected'],
});
expect(controller.isLoaded()).toBe(false);
});
});
describe('should lazy load', () => {
it('should load when both visible and intersecting', () => {
const controller = new LazyLoadController(createLitElement());
controller.setConfiguration({ lazyLoad: true });
controller.hostConnected();
expect(controller.isLoaded()).toBe(false);
callVisibilityHandler(true);
expect(controller.isLoaded()).toBe(false);
callIntersectionHandler(true);
expect(controller.isLoaded()).toBe(true);
});
});
describe('should lazy unload', () => {
it('should unload on DOM disconnection', () => {
const controller = new LazyLoadController(createLitElement());
// No lazy loading.
controller.setConfiguration({ lazyLoad: false });
controller.hostConnected();
expect(controller.isLoaded()).toBe(true);
controller.hostDisconnected();
expect(controller.isLoaded()).toBe(false);
// Should also stop observing.
expect(getMockIntersectionObserver()?.disconnect).toHaveBeenCalled();
expect(global.document.removeEventListener).toHaveBeenCalledWith(
'visibilitychange',
expect.anything(),
);
});
describe('should lazy unload when not visible', () => {
it.each([
[[], true],
[['unselected' as const], true],
[['hidden' as const], false],
[['unselected' as const, 'hidden' as const], false],
])(
'when unload conditions are: %s',
(unloadConditions: LazyUnloadCondition[], shouldBeLoaded: boolean) => {
const controller = new LazyLoadController(createLitElement());
controller.setConfiguration({
lazyLoad: true,
lazyUnloadConditions: unloadConditions,
});
controller.hostConnected();
callIntersectionHandler(true);
callVisibilityHandler(true);
expect(controller.isLoaded()).toBe(true);
callVisibilityHandler(false);
expect(controller.isLoaded()).toBe(shouldBeLoaded);
},
);
});
describe('should lazy unload when not intersecting', () => {
it.each([
[[], true],
[['unselected' as const], false],
[['hidden' as const], true],
[['unselected' as const, 'hidden' as const], false],
])(
'when unload conditions are: %s',
(unloadConditions: LazyUnloadCondition[], shouldBeLoaded: boolean) => {
const controller = new LazyLoadController(createLitElement());
controller.setConfiguration({
lazyLoad: true,
lazyUnloadConditions: unloadConditions,
});
controller.hostConnected();
callIntersectionHandler(true);
callVisibilityHandler(true);
expect(controller.isLoaded()).toBe(true);
callIntersectionHandler(false);
expect(controller.isLoaded()).toBe(shouldBeLoaded);
},
);
});
});
describe('should force selected', () => {
it('should load when forced selected even if not intersecting', () => {
const controller = new LazyLoadController(createLitElement());
controller.setConfiguration({ lazyLoad: true });
controller.hostConnected();
callVisibilityHandler(true);
expect(controller.isLoaded()).toBe(false);
controller.setConfiguration({ forceSelected: true });
expect(controller.isLoaded()).toBe(true);
});
it('should not load while document is already hidden on connect', () => {
Object.defineProperty(document, 'visibilityState', {
value: 'hidden',
writable: true,
configurable: true,
});
const controller = new LazyLoadController(createLitElement());
controller.setConfiguration({
lazyLoad: true,
lazyUnloadConditions: ['hidden'],
});
controller.hostConnected();
controller.setConfiguration({ forceSelected: true });
expect(controller.isLoaded()).toBe(false);
callVisibilityHandler(true);
expect(controller.isLoaded()).toBe(true);
});
it('should stay loaded with `unselected` unload condition while forced selected', () => {
const controller = new LazyLoadController(createLitElement());
controller.setConfiguration({
lazyLoad: true,
lazyUnloadConditions: ['unselected'],
forceSelected: true,
});
controller.hostConnected();
callVisibilityHandler(true);
expect(controller.isLoaded()).toBe(true);
callIntersectionHandler(false);
expect(controller.isLoaded()).toBe(true);
});
it('should still unload via `hidden` while forced selected', () => {
const controller = new LazyLoadController(createLitElement());
controller.setConfiguration({
lazyLoad: true,
lazyUnloadConditions: ['hidden'],
forceSelected: true,
});
controller.hostConnected();
callVisibilityHandler(true);
expect(controller.isLoaded()).toBe(true);
callVisibilityHandler(false);
expect(controller.isLoaded()).toBe(false);
});
it('should unload after force-selected is released and `unselected` applies', () => {
const controller = new LazyLoadController(createLitElement());
controller.setConfiguration({
lazyLoad: true,
lazyUnloadConditions: ['unselected'],
forceSelected: true,
});
controller.hostConnected();
callVisibilityHandler(true);
callIntersectionHandler(false);
expect(controller.isLoaded()).toBe(true);
controller.setConfiguration({
lazyLoad: true,
lazyUnloadConditions: ['unselected'],
forceSelected: false,
});
expect(controller.isLoaded()).toBe(false);
});
it('should keep selected stream loaded with default `unload: []`', () => {
const controller = new LazyLoadController(createLitElement());
controller.setConfiguration({ lazyLoad: true });
controller.hostConnected();
controller.setConfiguration({ forceSelected: true });
callVisibilityHandler(true);
expect(controller.isLoaded()).toBe(true);
controller.setConfiguration({ forceSelected: false });
callIntersectionHandler(false);
expect(controller.isLoaded()).toBe(true);
});
});
it('should call listeners', () => {
const listener = vi.fn();
const controller = new LazyLoadController(createLitElement());
controller.setConfiguration({
lazyLoad: true,
lazyUnloadConditions: ['unselected', 'hidden'],
});
controller.hostConnected();
controller.addListener(listener);
expect(controller.isLoaded()).toBe(false);
callIntersectionHandler(true);
callVisibilityHandler(true);
expect(listener).toHaveBeenLastCalledWith(true);
expect(listener).toHaveBeenCalledTimes(1);
callIntersectionHandler(false);
expect(listener).toHaveBeenLastCalledWith(false);
expect(listener).toHaveBeenCalledTimes(2);
callIntersectionHandler(true);
expect(listener).toHaveBeenLastCalledWith(true);
expect(listener).toHaveBeenCalledTimes(3);
controller.removeListener(listener);
callIntersectionHandler(false);
expect(listener).toHaveBeenCalledTimes(3);
});
});