refactor: Refactor media loading manager for improved robustness (#2464)

This commit is contained in:
Dermot Duffy
2026-06-30 17:45:12 -07:00
committed by dermotduffy
parent 47bcce93d3
commit bc366626f1
51 changed files with 1897 additions and 1128 deletions
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it, vi } from 'vitest';
import { onAbort } from '../../src/utils/abort-signal';
describe('onAbort', () => {
it('should call the callback when the signal aborts', () => {
const ac = new AbortController();
const cb = vi.fn();
onAbort(ac.signal, cb);
expect(cb).not.toBeCalled();
ac.abort();
expect(cb).toBeCalledTimes(1);
});
it('should call the callback synchronously if the signal is already aborted', () => {
const ac = new AbortController();
ac.abort();
const cb = vi.fn();
onAbort(ac.signal, cb);
expect(cb).toBeCalledTimes(1);
});
it('should fire only once even if the signal aborts repeatedly', () => {
const ac = new AbortController();
const cb = vi.fn();
onAbort(ac.signal, cb);
ac.abort();
// Aborting an AbortController again is a no-op, but verify the listener
// is registered with `once: true` so any synthetic re-fire would be a
// no-op too.
ac.signal.dispatchEvent(new Event('abort'));
expect(cb).toBeCalledTimes(1);
});
});
+2 -23
View File
@@ -1,7 +1,6 @@
import EmblaCarousel, { EmblaCarouselType } from 'embla-carousel';
import { MockedObject, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { CarouselController } from '../../../src/utils/embla/carousel-controller';
import AutoMediaLoadedInfo from '../../../src/utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info';
import {
MutationObserverMock,
callMutationHandler,
@@ -111,41 +110,23 @@ describe('CarouselController', () => {
const children = createTestSlideNodes();
const parent = createParent({ children: children });
const forceSelectListener = vi.fn();
parent.addEventListener(
'advanced-camera-card:carousel:force-select',
forceSelectListener,
);
const carousel = new CarouselController(createRoot(), parent);
carousel.selectSlide(4);
expect(getEmblaApi()?.scrollTo).toBeCalledWith(4, false);
expect(forceSelectListener).toBeCalledWith(
expect.objectContaining({
detail: { index: 4, element: children[4] },
}),
);
});
it('should not select non-existent slide', () => {
const children = createTestSlideNodes({ n: 10 });
const parent = createParent({ children: children });
const forceSelectListener = vi.fn();
parent.addEventListener(
'advanced-camera-card:carousel:force-select',
forceSelectListener,
);
const carousel = new CarouselController(createRoot(), parent);
carousel.selectSlide(11);
// Should not call scrollTo or fire event because index is out of bounds
// Should not call scrollTo because index is out of bounds.
expect(getEmblaApi()?.scrollTo).not.toBeCalled();
expect(forceSelectListener).not.toBeCalled();
});
it('should dispatch select event', () => {
@@ -191,7 +172,6 @@ describe('CarouselController', () => {
const children = createTestSlideNodes({ n: 1 });
const root = createRoot();
const parent = createParent({ children: children });
const plugins = [AutoMediaLoadedInfo()];
new CarouselController(root, parent, {
direction: 'vertical',
@@ -200,7 +180,6 @@ describe('CarouselController', () => {
dragFree: true,
loop: true,
dragEnabled: false,
plugins: plugins,
textDirection: 'rtl',
});
@@ -219,7 +198,7 @@ describe('CarouselController', () => {
watchDrag: false,
direction: 'rtl',
},
plugins,
[],
);
});
@@ -1,94 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import AutoMediaLoadedInfo from '../../../../../src/utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info';
import {
dispatchExistingMediaLoadedInfoAsEvent,
dispatchMediaUnloadedEvent,
} from '../../../../../src/utils/media-info';
import { createMediaLoadedInfo, createParent } from '../../../../test-utils';
import {
createEmblaApiInstance,
createTestEmblaOptionHandler,
createTestSlideNodes,
} from '../../test-utils';
// @vitest-environment jsdom
describe('AutoMediaLoadedInfo', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should construct', () => {
const plugin = AutoMediaLoadedInfo();
expect(plugin.name).toBe('autoMediaLoadedInfo');
});
it('should destroy', () => {
const plugin = AutoMediaLoadedInfo();
const emblaApi = createEmblaApiInstance();
plugin.init(emblaApi, createTestEmblaOptionHandler());
plugin.destroy();
expect(emblaApi.off).toBeCalledWith('init', expect.anything());
});
describe('should correctly propogate media load/unload depending on whether media is currently selected', () => {
it.each([
['loaded' as const, true],
['unloaded' as const, true],
['loaded' as const, false],
['unloaded' as const, false],
])('%s', (type: string, selected: boolean) => {
const plugin = AutoMediaLoadedInfo();
const children = createTestSlideNodes();
const parent = createParent({ children: children });
const emblaApi = createEmblaApiInstance({
containerNode: parent,
slideNodes: children,
selectedScrollSnap: selected ? 5 : 4,
});
plugin.init(emblaApi, createTestEmblaOptionHandler());
const mediaLoadedHandler = vi.fn();
parent.addEventListener('advanced-camera-card:media:' + type, mediaLoadedHandler);
if (type === 'loaded') {
dispatchExistingMediaLoadedInfoAsEvent(children[5], createMediaLoadedInfo());
} else if (type === 'unloaded') {
dispatchMediaUnloadedEvent(children[5]);
}
if (selected) {
expect(mediaLoadedHandler).toBeCalled();
} else {
expect(mediaLoadedHandler).not.toBeCalled();
}
});
});
it('selecting a slide should dispatch a previously saved media loaded info if present', () => {
const plugin = AutoMediaLoadedInfo();
const children = createTestSlideNodes();
const parent = createParent({ children: children });
const emblaApi = createEmblaApiInstance({
containerNode: parent,
slideNodes: children,
});
plugin.init(emblaApi, createTestEmblaOptionHandler());
const mediaLoadedHandler = vi.fn();
parent.addEventListener('advanced-camera-card:media:loaded', mediaLoadedHandler);
dispatchExistingMediaLoadedInfoAsEvent(children[5], createMediaLoadedInfo());
vi.mocked(emblaApi.selectedScrollSnap).mockReturnValue(4);
emblaApi
.containerNode()
.dispatchEvent(new Event('advanced-camera-card:carousel:force-select'));
expect(mediaLoadedHandler).not.toBeCalled();
vi.mocked(emblaApi.selectedScrollSnap).mockReturnValue(5);
emblaApi
.containerNode()
.dispatchEvent(new Event('advanced-camera-card:carousel:force-select'));
expect(mediaLoadedHandler).toBeCalled();
});
});
-17
View File
@@ -1,25 +1,8 @@
import { EmblaCarouselType, EmblaEventType } from 'embla-carousel';
import { EngineType } from 'embla-carousel/components/Engine';
import { LooseOptionsType } from 'embla-carousel/components/Options';
import { OptionsHandlerType } from 'embla-carousel/components/OptionsHandler';
import { merge } from 'lodash-es';
import { vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
export const createTestEmblaOptionHandler = (): OptionsHandlerType => ({
mergeOptions: <TypeA extends LooseOptionsType, TypeB extends LooseOptionsType>(
optionsA: TypeA,
optionsB?: TypeB,
): TypeA => {
return merge({}, optionsA, optionsB);
},
optionsAtMedia: <Type extends LooseOptionsType>(options: Type): Type => {
return options;
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
optionsMediaQueries: (_optionsList: LooseOptionsType[]): MediaQueryList[] => [],
});
export const callEmblaHandler = (
emblaApi: EmblaCarouselType | null,
eventName: EmblaEventType,
-71
View File
@@ -3,11 +3,8 @@ import { mock } from 'vitest-mock-extended';
import { MediaLoadedCapabilities, MediaPlayer } from '../../src/types';
import {
createMediaLoadedInfo,
dispatchExistingMediaLoadedInfoAsEvent,
dispatchMediaLoadedEvent,
dispatchMediaPauseEvent,
dispatchMediaPlayEvent,
dispatchMediaUnloadedEvent,
dispatchMediaVolumeChangeEvent,
isValidMediaLoadedInfo,
} from '../../src/utils/media-info';
@@ -83,74 +80,6 @@ describe('createMediaLoadedInfo', () => {
});
});
// @vitest-environment jsdom
describe('dispatchMediaLoadedEvent', () => {
const options = {
player: mock<MediaPlayer>(),
capabilities: mock<MediaLoadedCapabilities>(),
};
it('should dispatch', () => {
const handler = vi.fn();
const div = document.createElement('div');
div.addEventListener('advanced-camera-card:media:loaded', handler);
// Need to write readonly properties.
const img = document.createElement('img');
Object.defineProperty(img, 'naturalWidth', { value: 10 });
Object.defineProperty(img, 'naturalHeight', { value: 20 });
dispatchMediaLoadedEvent(div, img, options);
expect(handler).toBeCalledWith(
expect.objectContaining({
detail: {
width: 10,
height: 20,
...options,
},
}),
);
});
it('should not dispatch', () => {
const handler = vi.fn();
const div = document.createElement('div');
div.addEventListener('advanced-camera-card:media:loaded', handler);
dispatchMediaLoadedEvent(div, div, options);
expect(handler).not.toBeCalled();
});
});
// @vitest-environment jsdom
describe('dispatchExistingMediaLoadedInfoAsEvent', () => {
it('should dispatch', () => {
const handler = vi.fn();
const div = document.createElement('div');
div.addEventListener('advanced-camera-card:media:loaded', handler);
const info = createTestMediaLoadedInfo();
dispatchExistingMediaLoadedInfoAsEvent(div, info);
expect(handler).toBeCalledWith(
expect.objectContaining({
detail: info,
}),
);
});
});
// @vitest-environment jsdom
describe('dispatchMediaUnloadedEvent', () => {
it('should dispatch', () => {
const handler = vi.fn();
const div = document.createElement('div');
div.addEventListener('advanced-camera-card:media:unloaded', handler);
dispatchMediaUnloadedEvent(div);
expect(handler).toBeCalled();
});
});
// @vitest-environment jsdom
describe('dispatchMediaVolumeChangeEvent', () => {
it('should dispatch', () => {