maplibre-gl
Version:
BSD licensed community fork of mapbox-gl, a WebGL interactive maps library
275 lines (229 loc) • 8.66 kB
text/typescript
import {describe, beforeEach, afterEach, test, expect, vi, type MockInstance} from 'vitest';
import {createMap, beforeMapTest, createStyle, sleep} from '../../util/test/util.ts';
import {fakeServer, type FakeServer} from 'nise';
import {PauseablePlacement} from '../../style/pauseable_placement.ts';
import {now, setNow, restoreNow} from '../../util/time_control.ts';
import type {Map} from '../map.ts';
let server: FakeServer;
beforeEach(() => {
beforeMapTest();
global.fetch = null;
server = fakeServer.create();
});
afterEach(() => {
server.restore();
});
test('render stabilizes', async () => {
const style = createStyle();
style.sources.maplibre = {
type: 'vector',
minzoom: 1,
maxzoom: 10,
tiles: ['http://example.com/{z}/{x}/{y}.png']
};
style.layers.push({
id: 'layerId',
type: 'circle',
source: 'maplibre',
'source-layer': 'sourceLayer'
});
let timer;
const map = createMap({style});
const spy = vi.fn();
map.on('render', () => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
map.off('render', undefined);
map.on('render', () => {
throw new Error('test failed');
});
expect((map as any)._frameId).toBeFalsy();
spy();
}, 100);
});
await sleep(700);
expect(spy).toHaveBeenCalled();
});
test('no render after idle event', async () => {
const style = createStyle();
const map = createMap({style});
await map.once('idle');
const spy = vi.fn();
map.on('render', spy);
await sleep(100);
expect(spy).not.toHaveBeenCalled();
});
test('no render before style loaded', async () => {
server.respondWith('/styleUrl', JSON.stringify(createStyle()));
const map = createMap({style: '/styleUrl'});
vi.spyOn(map, 'triggerRepaint').mockImplementationOnce(() => {
if (!map.style._loaded) {
throw new Error('test failed');
}
});
let loaded = true;
map.on('render', () => {
loaded = map.style._loaded;
});
// Force a update should not call triggerRepaint till style is loaded.
// Once style is loaded, it will trigger the update.
map._update();
expect(loaded).toBeTruthy();
server.respond();
expect(loaded).toBeTruthy();
});
test('redraw', async () => {
const map = createMap();
await map.once('idle');
const renderPromise = map.once('render');
map.redraw();
await expect(renderPromise).resolves.toBeDefined();
});
describe('symbol placement re-runs', () => {
let map: Map;
let time: number;
let placementRun: MockInstance<PauseablePlacement['continuePlacement']>;
/** Leaves placement committed and past its recency window, so the next run is free to be skipped. */
function settle() {
map.redraw();
setNow(time += 1000);
placementRun.mockClear();
}
beforeEach(async () => {
placementRun = vi.spyOn(PauseablePlacement.prototype, 'continuePlacement');
map = createMap({style: {
version: 8,
sources: {geojson: {type: 'geojson', data: {type: 'FeatureCollection', features: []}}},
layers: [{id: 'symbol', type: 'symbol', source: 'geojson'}]
}});
await map.once('idle');
time = now();
setNow(time);
});
afterEach(() => {
placementRun.mockRestore();
restoreNow();
});
test('placement is not re-run while its inputs are unchanged', () => {
settle();
map.redraw();
expect(placementRun).not.toHaveBeenCalled();
});
test('a zoom change re-places, and then settles again instead of looping', () => {
settle();
map.setZoom(3);
map.redraw();
expect(placementRun).toHaveBeenCalled();
settle();
map.redraw();
expect(placementRun).not.toHaveBeenCalled();
});
test('a paint property change re-places, and then settles again instead of looping', () => {
settle();
map.setPaintProperty('symbol', 'icon-translate', [5, 5]);
map.redraw();
expect(placementRun).toHaveBeenCalled();
settle();
map.redraw();
expect(placementRun).not.toHaveBeenCalled();
});
test('a renderWorldCopies change re-places, and then settles again instead of looping', () => {
settle();
map.setRenderWorldCopies(false);
map.redraw();
expect(placementRun).toHaveBeenCalled();
settle();
map.redraw();
expect(placementRun).not.toHaveBeenCalled();
});
test('a showCollisionBoxes change re-places, and then settles again instead of looping', () => {
settle();
map.showCollisionBoxes = true;
map.redraw();
expect(placementRun).toHaveBeenCalled();
settle();
map.redraw();
expect(placementRun).not.toHaveBeenCalled();
});
test('a stale placement gets its final re-run even after its inputs settled', () => {
settle();
map.style.placement.setStale();
map.redraw();
expect(placementRun).toHaveBeenCalled();
placementRun.mockClear();
setNow(time += 1000);
map.redraw();
expect(placementRun).not.toHaveBeenCalled();
});
});
describe('symbol fade after the placement guard', () => {
afterEach(() => {
restoreNow();
});
test('a placement change keeps the map rendering through fadeDuration, then it goes idle', async () => {
const map = createMap({style: {
version: 8,
sources: {geojson: {type: 'geojson', data: {type: 'FeatureCollection', features: [
{type: 'Feature', geometry: {type: 'Point', coordinates: [-20, 0]}, properties: {}},
{type: 'Feature', geometry: {type: 'Point', coordinates: [20, 0]}, properties: {}}
]}}},
layers: []
}});
await map.once('load');
map.addImage('dot', {width: 80, height: 80, data: new Uint8Array(80 * 80 * 4)});
map.addLayer({id: 'symbol', type: 'symbol', source: 'geojson', layout: {'icon-image': 'dot'}});
await map.once('idle');
const idle = vi.fn();
map.on('idle', idle);
let time = now();
setNow(time);
// Zooming in separates the icons, so the one that lost the collision fades in.
map.setZoom(0.9);
map.redraw();
setNow(time += 100);
map.redraw();
expect(idle).not.toHaveBeenCalled();
setNow(time += 1000);
map.redraw();
expect(idle).toHaveBeenCalled();
});
});
describe('render-to-texture follow-up frame', () => {
test('keeps rendering, deferring idle, until the follow-up frame is no longer needed', async () => {
const map = createMap();
await map.once('idle');
const rtt = {needsFollowUpFrame: true, prepareForRender: () => {}, renderLayer: () => false, getTexture: () => null};
map.painter.renderToTexture = rtt;
const idle = vi.fn();
map.on('idle', idle);
const repaint = vi.spyOn(map, 'triggerRepaint');
map.redraw();
expect(repaint).toHaveBeenCalled();
expect(idle).not.toHaveBeenCalled();
rtt.needsFollowUpFrame = false;
map.redraw();
expect(idle).toHaveBeenCalled();
map.remove();
});
});
describe('hidden layers', () => {
test('a layer hidden at the current zoom does not render tile clipping masks for its source', async () => {
const square: GeoJSON.Feature = {type: 'Feature', geometry: {type: 'Polygon', coordinates: [[[-10, -10], [10, -10], [10, 10], [-10, 10], [-10, -10]]]}, properties: {}};
const map = createMap({style: {
version: 8,
sources: {shared: {type: 'geojson', data: square}, other: {type: 'geojson', data: square}},
layers: [
{id: 'shared-fill', type: 'fill', source: 'shared'},
{id: 'other-fill', type: 'fill', source: 'other'},
{id: 'shared-fill-hidden-below-zoom-10', type: 'fill', source: 'shared', minzoom: 10}
]
}});
const lastSourceToRenderClippingMasks = () => map.painter.currentStencilSource;
await map.once('idle');
expect(lastSourceToRenderClippingMasks()).toBe('other');
map.setLayerZoomRange('shared-fill-hidden-below-zoom-10', 0, 24);
await map.once('idle');
expect(lastSourceToRenderClippingMasks()).toBe('shared');
map.remove();
});
});