feat: Add live.microphone.auto_mute / auto_unmute (#2482)
This commit is contained in:
committed by
dermotduffy
parent
9eb503deff
commit
a68b665f03
@@ -0,0 +1,330 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { MicrophoneManager } from '../../../src/card-controller/microphone-manager';
|
||||
import { MicrophoneActionsController } from '../../../src/components-lib/live/microphone-actions-controller';
|
||||
import {
|
||||
IntersectionObserverMock,
|
||||
callIntersectionHandler,
|
||||
callVisibilityHandler,
|
||||
createParent,
|
||||
getMockIntersectionObserver,
|
||||
} from '../../test-utils';
|
||||
|
||||
const createMicrophoneManager = (): MicrophoneManager => {
|
||||
const microphoneManager = mock<MicrophoneManager>();
|
||||
vi.mocked(microphoneManager.unmute).mockResolvedValue(undefined);
|
||||
return microphoneManager;
|
||||
};
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('MicrophoneActionsController', () => {
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal('IntersectionObserver', IntersectionObserverMock);
|
||||
// callVisibilityHandler reads from this spy.
|
||||
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('on selected camera change', () => {
|
||||
it('should unmute on selected when a camera becomes selected', async () => {
|
||||
const microphoneManager = createMicrophoneManager();
|
||||
const controller = new MicrophoneActionsController();
|
||||
controller.setOptions({
|
||||
microphoneManager,
|
||||
autoUnmuteConditions: ['selected' as const],
|
||||
});
|
||||
|
||||
await controller.setSelectedCamera('camera-1');
|
||||
|
||||
expect(microphoneManager.unmute).toBeCalledTimes(1);
|
||||
expect(microphoneManager.mute).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should mute on unselected when transitioning to a new camera', async () => {
|
||||
const microphoneManager = createMicrophoneManager();
|
||||
const controller = new MicrophoneActionsController();
|
||||
controller.setOptions({
|
||||
microphoneManager,
|
||||
autoMuteConditions: ['unselected' as const],
|
||||
});
|
||||
|
||||
await controller.setSelectedCamera('camera-1');
|
||||
await controller.setSelectedCamera('camera-2');
|
||||
|
||||
expect(microphoneManager.mute).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should sequence mute-then-unmute deterministically on transition', async () => {
|
||||
// Why this ordering matters: in grid mode, each camera cell owns its own
|
||||
// MediaActionsController, but every cell shares the global
|
||||
// MicrophoneManager. Without a single coordinator, a B->A selection
|
||||
// change would have cell B fire 'unselected' (mute) and cell A fire
|
||||
// 'selected' (unmute) independently, with order decided by Lit's sibling
|
||||
// update sequence -- leaving the final mic state nondeterministic, and
|
||||
// sometimes ending up muted despite `auto_unmute: ['selected']` being
|
||||
// configured. This controller is that single coordinator: it always emits
|
||||
// unselected then selected so the arriver wins.
|
||||
const microphoneManager = createMicrophoneManager();
|
||||
const controller = new MicrophoneActionsController();
|
||||
const callOrder: string[] = [];
|
||||
vi.mocked(microphoneManager.mute).mockImplementation(() => {
|
||||
callOrder.push('mute');
|
||||
});
|
||||
vi.mocked(microphoneManager.unmute).mockImplementation(async () => {
|
||||
callOrder.push('unmute');
|
||||
});
|
||||
controller.setOptions({
|
||||
microphoneManager,
|
||||
autoMuteConditions: ['unselected' as const],
|
||||
autoUnmuteConditions: ['selected' as const],
|
||||
});
|
||||
|
||||
await controller.setSelectedCamera('camera-A');
|
||||
callOrder.length = 0;
|
||||
|
||||
await controller.setSelectedCamera('camera-B');
|
||||
|
||||
expect(callOrder).toEqual(['mute', 'unmute']);
|
||||
});
|
||||
|
||||
it('should not refire when the same camera is set again', async () => {
|
||||
const microphoneManager = createMicrophoneManager();
|
||||
const controller = new MicrophoneActionsController();
|
||||
controller.setOptions({
|
||||
microphoneManager,
|
||||
autoUnmuteConditions: ['selected' as const],
|
||||
});
|
||||
|
||||
await controller.setSelectedCamera('camera-1');
|
||||
await controller.setSelectedCamera('camera-1');
|
||||
|
||||
expect(microphoneManager.unmute).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should fire unselected only when transitioning from a camera to none', async () => {
|
||||
// camera=null path: previous exists, new is null. Only mute fires.
|
||||
const microphoneManager = createMicrophoneManager();
|
||||
const controller = new MicrophoneActionsController();
|
||||
controller.setOptions({
|
||||
microphoneManager,
|
||||
autoMuteConditions: ['unselected' as const],
|
||||
autoUnmuteConditions: ['selected' as const],
|
||||
});
|
||||
|
||||
await controller.setSelectedCamera('camera-1');
|
||||
vi.mocked(microphoneManager.unmute).mockClear();
|
||||
vi.mocked(microphoneManager.mute).mockClear();
|
||||
|
||||
await controller.setSelectedCamera(null);
|
||||
|
||||
expect(microphoneManager.mute).toBeCalledTimes(1);
|
||||
expect(microphoneManager.unmute).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should not fire unselected on the very first selection (no previous)', async () => {
|
||||
const microphoneManager = createMicrophoneManager();
|
||||
const controller = new MicrophoneActionsController();
|
||||
controller.setOptions({
|
||||
microphoneManager,
|
||||
autoMuteConditions: ['unselected' as const],
|
||||
autoUnmuteConditions: ['selected' as const],
|
||||
});
|
||||
|
||||
await controller.setSelectedCamera('camera-1');
|
||||
|
||||
expect(microphoneManager.mute).not.toBeCalled();
|
||||
expect(microphoneManager.unmute).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not fire when condition arrays are empty', async () => {
|
||||
const microphoneManager = createMicrophoneManager();
|
||||
const controller = new MicrophoneActionsController();
|
||||
controller.setOptions({
|
||||
microphoneManager,
|
||||
autoMuteConditions: [],
|
||||
autoUnmuteConditions: [],
|
||||
});
|
||||
|
||||
await controller.setSelectedCamera('camera-1');
|
||||
await controller.setSelectedCamera('camera-2');
|
||||
|
||||
expect(microphoneManager.mute).not.toBeCalled();
|
||||
expect(microphoneManager.unmute).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should not crash when conditions configured but no microphone manager is passed', async () => {
|
||||
// Guards the short-circuit on the helpers: with conditions configured
|
||||
// but no microphoneManager, .mute()/.unmute() must not be invoked on
|
||||
// undefined.
|
||||
const controller = new MicrophoneActionsController();
|
||||
controller.setOptions({
|
||||
autoMuteConditions: ['unselected' as const],
|
||||
autoUnmuteConditions: ['selected' as const],
|
||||
});
|
||||
|
||||
await expect(controller.setSelectedCamera('camera-1')).resolves.toBeUndefined();
|
||||
await expect(controller.setSelectedCamera('camera-2')).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('on document visibility change', () => {
|
||||
it('should mute on hidden when the live root is intersecting', async () => {
|
||||
const microphoneManager = createMicrophoneManager();
|
||||
const controller = new MicrophoneActionsController();
|
||||
controller.setOptions({
|
||||
microphoneManager,
|
||||
autoMuteConditions: ['hidden' as const],
|
||||
});
|
||||
controller.setRoot(createParent());
|
||||
|
||||
// baseline: visible=true
|
||||
await callIntersectionHandler(true);
|
||||
|
||||
await callVisibilityHandler(false);
|
||||
|
||||
expect(microphoneManager.mute).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should unmute on visible when the live root is intersecting', async () => {
|
||||
const microphoneManager = createMicrophoneManager();
|
||||
const controller = new MicrophoneActionsController();
|
||||
controller.setOptions({
|
||||
microphoneManager,
|
||||
autoUnmuteConditions: ['visible' as const],
|
||||
});
|
||||
controller.setRoot(createParent());
|
||||
|
||||
// baseline: visible=true
|
||||
await callIntersectionHandler(true);
|
||||
|
||||
// visible -> hidden
|
||||
await callVisibilityHandler(false);
|
||||
vi.mocked(microphoneManager.unmute).mockClear();
|
||||
|
||||
await callVisibilityHandler(true);
|
||||
|
||||
expect(microphoneManager.unmute).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not unmute on tab visible when the live root is hidden', async () => {
|
||||
// With live.preload, the live element stays in DOM but is hidden via
|
||||
// display:none in non-live views. The intersection observer reports false
|
||||
// for that, so VisibilityObserver suppresses the unmute even when the tab
|
||||
// regains focus. Without this gate, tab focus would prompt for / open the
|
||||
// microphone from a hidden live view.
|
||||
const microphoneManager = createMicrophoneManager();
|
||||
const controller = new MicrophoneActionsController();
|
||||
controller.setOptions({
|
||||
microphoneManager,
|
||||
autoUnmuteConditions: ['visible' as const],
|
||||
});
|
||||
controller.setRoot(createParent());
|
||||
await callIntersectionHandler(false); // baseline: element not visible
|
||||
|
||||
await callVisibilityHandler(false);
|
||||
await callVisibilityHandler(true);
|
||||
|
||||
expect(microphoneManager.unmute).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('on intersection change', () => {
|
||||
it('should mute when the live root scrolls out of view', async () => {
|
||||
const microphoneManager = createMicrophoneManager();
|
||||
const controller = new MicrophoneActionsController();
|
||||
controller.setOptions({
|
||||
microphoneManager,
|
||||
autoMuteConditions: ['hidden' as const],
|
||||
});
|
||||
controller.setRoot(createParent());
|
||||
|
||||
// First intersection callback establishes baseline; only true transitions
|
||||
// thereafter trigger actions.
|
||||
await callIntersectionHandler(true);
|
||||
await callIntersectionHandler(false);
|
||||
|
||||
expect(microphoneManager.mute).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should unmute when the live root scrolls back into view', async () => {
|
||||
const microphoneManager = createMicrophoneManager();
|
||||
const controller = new MicrophoneActionsController();
|
||||
controller.setOptions({
|
||||
microphoneManager,
|
||||
autoUnmuteConditions: ['visible' as const],
|
||||
});
|
||||
controller.setRoot(createParent());
|
||||
|
||||
await callIntersectionHandler(false);
|
||||
await callIntersectionHandler(true);
|
||||
|
||||
expect(microphoneManager.unmute).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should ignore the very first intersection callback (baseline)', async () => {
|
||||
const microphoneManager = createMicrophoneManager();
|
||||
const controller = new MicrophoneActionsController();
|
||||
controller.setOptions({
|
||||
microphoneManager,
|
||||
autoMuteConditions: ['hidden' as const],
|
||||
autoUnmuteConditions: ['visible' as const],
|
||||
});
|
||||
controller.setRoot(createParent());
|
||||
|
||||
await callIntersectionHandler(false);
|
||||
|
||||
expect(microphoneManager.mute).not.toBeCalled();
|
||||
expect(microphoneManager.unmute).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('lifecycle', () => {
|
||||
it('should be idempotent on setRoot for the same element', () => {
|
||||
const controller = new MicrophoneActionsController();
|
||||
const parent = createParent();
|
||||
|
||||
controller.setRoot(parent);
|
||||
const intersectionObserver = getMockIntersectionObserver();
|
||||
expect(intersectionObserver?.observe).toHaveBeenCalledTimes(1);
|
||||
expect(intersectionObserver?.disconnect).toHaveBeenCalledTimes(1);
|
||||
|
||||
controller.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 controller = new MicrophoneActionsController();
|
||||
controller.setRoot(createParent());
|
||||
|
||||
const intersectionObserver = getMockIntersectionObserver();
|
||||
|
||||
controller.destroy();
|
||||
|
||||
expect(intersectionObserver?.disconnect).toHaveBeenCalled();
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
||||
'visibilitychange',
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -53,6 +53,15 @@ describe('MediaActionsController', () => {
|
||||
|
||||
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('should set root', () => {
|
||||
@@ -451,14 +460,16 @@ describe('MediaActionsController', () => {
|
||||
controller.setRoot(createParent({ children: children }));
|
||||
await controller.setTarget(0, true);
|
||||
|
||||
// Not configured to take action on selection.
|
||||
expect(
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.[func],
|
||||
).not.toBeCalled();
|
||||
// The 'visible' rule fires when the element is both intersecting and
|
||||
// the tab is visible. Set up element-intersecting + tab-hidden first so
|
||||
// that the next callVisibilityHandler(true) is a real hidden->visible
|
||||
// transition. The hidden transition itself only fires pause/mute (not
|
||||
// play/unmute), so it does not affect the call count of `func` here.
|
||||
await callIntersectionHandler(true);
|
||||
await callVisibilityHandler(false);
|
||||
|
||||
await callVisibilityHandler(true);
|
||||
|
||||
// Not configured to take action on selection.
|
||||
expect(
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.[func],
|
||||
).toBeCalledTimes(called ? 1 : 0);
|
||||
@@ -494,14 +505,13 @@ describe('MediaActionsController', () => {
|
||||
controller.setRoot(createParent({ children: children }));
|
||||
await controller.setTarget(0, true);
|
||||
|
||||
// Not configured to take action on selection.
|
||||
expect(
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.[func],
|
||||
).not.toBeCalled();
|
||||
// The 'hidden' rule fires on a transition from visible to hidden.
|
||||
// Establish element-intersecting + tab-visible first so that
|
||||
// callVisibilityHandler(false) is a real visible->hidden transition.
|
||||
await callIntersectionHandler(true);
|
||||
|
||||
await callVisibilityHandler(false);
|
||||
|
||||
// Not configured to take action on selection.
|
||||
expect(
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.[func],
|
||||
).toBeCalledTimes(called ? 1 : 0);
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { VisibilityObserver } from '../../src/components-lib/visibility-observer';
|
||||
import {
|
||||
IntersectionObserverMock,
|
||||
callIntersectionHandler,
|
||||
callVisibilityHandler,
|
||||
createParent,
|
||||
getMockIntersectionObserver,
|
||||
} 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),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user