maplibre-gl
Version:
BSD licensed community fork of mapbox-gl, a WebGL interactive maps library
248 lines (200 loc) • 10.4 kB
text/typescript
import {describe, test, expect, beforeEach, vi, afterEach} from 'vitest';
import {beforeMapTest, createMap as globalCreateMap} from './test/util.ts';
import {browser} from './browser.ts';
import {AbortError} from './abort_error.ts';
describe('browser', () => {
describe('frame',() => {
let originalRAF: typeof window.requestAnimationFrame;
let originalCAF: typeof window.cancelAnimationFrame;
let rafCallbacks: Array<{id: number; callback: FrameRequestCallback}> = [];
let rafIdCounter = 0;
/** Mimic scheduling RAFs for later */
function flushAllRAFs() {
const pending = [...rafCallbacks];
rafCallbacks = [];
for (const {callback} of pending) {
callback(performance.now());
}
}
beforeEach(() => {
originalRAF = window.requestAnimationFrame;
originalCAF = window.cancelAnimationFrame;
rafCallbacks = [];
rafIdCounter = 0;
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(cb => {
rafIdCounter++;
const id = rafIdCounter;
rafCallbacks.push({id, callback: cb});
return id;
});
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => {
rafCallbacks = rafCallbacks.filter(entry => entry.id !== id);
});
});
afterEach(() => {
window.requestAnimationFrame = originalRAF;
window.cancelAnimationFrame = originalCAF;
vi.restoreAllMocks();
});
test('calls requestAnimationFrame and invokes fn callback with timestamp', () => {
const abortController = new AbortController();
const addListenerSpy = vi.spyOn(abortController.signal, 'addEventListener');
const removeListenerSpy = vi.spyOn(abortController.signal, 'removeEventListener');
const fn = vi.fn();
const reject = vi.fn();
browser.frame(abortController, fn, reject);
expect(window.requestAnimationFrame).toHaveBeenCalledTimes(1);
flushAllRAFs();
expect(fn).toHaveBeenCalledTimes(1);
const callArg = fn.mock.calls[0][0];
expect(callArg).toBeTypeOf('number');
expect(window.cancelAnimationFrame).not.toHaveBeenCalled();
expect(reject).not.toHaveBeenCalled();
// cleanup leftover listeners
expect(addListenerSpy).toHaveBeenCalledWith('abort', expect.any(Function), false);
expect(removeListenerSpy).toHaveBeenCalledWith('abort', expect.any(Function), false);
});
test('when AbortController is aborted before frame fires, calls cancelAnimationFrame and reject', () => {
// We override the default mock so that the callback is NOT called immediately
// giving us time to abort.
(vi.mocked(window.requestAnimationFrame)).mockReturnValue(
42
);
const abortController = new AbortController();
const addListenerSpy = vi.spyOn(abortController.signal, 'addEventListener');
const removeListenerSpy = vi.spyOn(abortController.signal, 'removeEventListener');
const fn = vi.fn();
const reject = vi.fn();
browser.frame(abortController, fn, reject);
abortController.abort();
// Now we expect cancelAnimationFrame to be called with the ID 42
expect(window.cancelAnimationFrame).toHaveBeenCalledTimes(1);
expect(window.cancelAnimationFrame).toHaveBeenCalledWith(42);
// Expect reject to be called
expect(reject).toHaveBeenCalledTimes(1);
const errorArg = reject.mock.calls[0][0];
expect(errorArg).toBeInstanceOf(Error);
expect(errorArg.message).toMatch(/abort/i);
// fn should never have been called because we never triggered the RAF callback
expect(fn).not.toHaveBeenCalled();
// cleanup leftover listeners
expect(addListenerSpy).toHaveBeenCalledWith('abort', expect.any(Function), false);
expect(removeListenerSpy).toHaveBeenCalledWith('abort', expect.any(Function), false);
});
test('when AbortController is aborted after frame fires, fn is invoked anyway', () => {
const abortController = new AbortController();
const addListenerSpy = vi.spyOn(abortController.signal, 'addEventListener');
const removeListenerSpy = vi.spyOn(abortController.signal, 'removeEventListener');
const fn = vi.fn();
const reject = vi.fn();
browser.frame(abortController, fn, reject);
flushAllRAFs();
// The callback should have already been called
expect(fn).toHaveBeenCalledTimes(1);
// The callback runs immediately in our default mock
// so if we abort now, it's too late to cancel the frame
abortController.abort();
// Because callback already fired, there's no need to cancel
expect(window.cancelAnimationFrame).not.toHaveBeenCalled();
// And reject shouldn't be called either
expect(reject).not.toHaveBeenCalled();
// cleanup leftover listeners
expect(addListenerSpy).toHaveBeenCalledWith('abort', expect.any(Function), false);
expect(removeListenerSpy).toHaveBeenCalledWith('abort', expect.any(Function), false);
});
});
describe('frameAsync',()=>{
test('expect RAF to be called and receive RAF id', async () => {
const id = await browser.frameAsync(new AbortController());
expect(id).toBeTruthy();
});
test('throw error when abort is called', async () => {
const abortController = new AbortController();
const promise = browser.frameAsync(abortController);
abortController.abort();
await expect(promise).rejects.toThrow(AbortError);
});
});
describe('reduceMotion', () => {
const createMap = (options: {reduceMotion?: boolean}) => {
beforeMapTest();
const container = window.document.createElement('div');
window.document.body.appendChild(container);
Object.defineProperty(container, 'clientWidth', {value: 512});
Object.defineProperty(container, 'clientHeight', {value: 512});
return globalCreateMap({container, ...options});
};
test('reduceMotion set to true', () => {
createMap({reduceMotion: true});
expect(browser.prefersReducedMotion).toBe(true);
});
test('reduceMotion set to false', () => {
createMap({reduceMotion: false});
expect(browser.prefersReducedMotion).toBe(false);
});
test('reduceMotion set to undefined', () => {
const browserDefault = matchMedia('(prefers-reduced-motion: reduce)').matches;
createMap({});
expect(browser.prefersReducedMotion).toBe(browserDefault);
});
});
test('hardwareConcurrency', () => {
expect(browser.hardwareConcurrency).toBeTypeOf('number');
});
describe('getImageCanvasContext', () => {
const image = {width: 4, height: 3} as unknown as ImageBitmap;
/**
* A canvas that records `drawImage` instead of rasterising. `getImageData` answers with the
* bytes `isOffscreenCanvasDistorted` expects, or with zeroes to fail it (see #3185).
*/
function createFakeCanvas(distorted = false) {
const canvas = {width: 0, height: 0, getContext: () => context};
const context = {
canvas,
fillRect: () => {},
drawImage: vi.fn(),
getImageData: (_x: number, _y: number, width: number, height: number) => ({
data: Uint8ClampedArray.from({length: width * height * 4}, (_, i) => distorted ? 0 : i)
})
};
return canvas;
}
/**
* Draws `image` through a fresh copy of `browser.ts`, since the two `OffscreenCanvas` probes
* behind `getImageCanvasContext` cache their answer in module scope. Only the document canvas
* comes back sized, so `context.canvas.width` says which path ran.
*/
async function drawImageWith(OffscreenCanvas: unknown) {
vi.stubGlobal('OffscreenCanvas', OffscreenCanvas);
vi.stubGlobal('createImageBitmap', vi.fn());
vi.spyOn(window.document, 'createElement').mockReturnValue(createFakeCanvas() as unknown as HTMLElement);
vi.resetModules();
return (await import('./browser.ts')).browser.getImageCanvasContext(image);
}
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
vi.resetModules();
});
test('draws into an OffscreenCanvas sized to the image when the browser has one that reads back faithfully', async () => {
const OffscreenCanvasStub = vi.fn(function () {
return createFakeCanvas();
});
const context = await drawImageWith(OffscreenCanvasStub);
expect(OffscreenCanvasStub).toHaveBeenCalledWith(4, 3);
expect(context.drawImage).toHaveBeenCalledWith(image, 0, 0, 4, 3);
});
test('draws into a document canvas sized to the image when the browser has no OffscreenCanvas', async () => {
const context = await drawImageWith(undefined);
expect([context.canvas.width, context.canvas.height]).toEqual([4, 3]);
expect(context.drawImage).toHaveBeenCalledWith(image, 0, 0, 4, 3);
});
test('falls back to a document canvas when the OffscreenCanvas distorts pixels', async () => {
const context = await drawImageWith(vi.fn(function () {
return createFakeCanvas(true);
}));
expect([context.canvas.width, context.canvas.height]).toEqual([4, 3]);
expect(context.drawImage).toHaveBeenCalledWith(image, 0, 0, 4, 3);
});
});
});