Files
TREK/client/tests/unit/sync/glPrefetcher.test.ts
T

199 lines
7.8 KiB
TypeScript

/**
* glPrefetcher unit tests.
*
* Covers: style/sprite/glyph warming, the TileJSON indirection, the cors
* requirement, the offline and cancellation guards, cache reuse, and clearing.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import 'fake-indexeddb/auto';
import {
prefetchStyleAssets,
prefetchVectorForPlaces,
clearVectorCache,
} from '../../../src/sync/glPrefetcher';
import { buildPlace } from '../../helpers/factories';
const STYLE_URL = 'https://tiles.openfreemap.org/styles/positron';
const TILE_TEMPLATE = 'https://tiles.openfreemap.org/planet/20260101_001001055/{z}/{x}/{y}.pbf';
const STYLE_DOC = {
sprite: 'https://tiles.openfreemap.org/sprites/ofm_f384/ofm',
glyphs: 'https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf',
sources: { openmaptiles: { url: 'https://tiles.openfreemap.org/planet' } },
layers: [
{ layout: { 'text-font': ['Noto Sans Regular'] } },
{ layout: { 'text-font': ['Noto Sans Regular'] } }, // same stack, must dedupe
{ layout: { 'text-font': ['Noto Sans Italic'] } },
{ layout: {} },
{},
],
};
/** Cache Storage stand-in: jsdom has none, and we want to inspect what landed. */
class FakeCache {
store = new Map<string, unknown>();
match(url: string) {
return Promise.resolve(this.store.get(url));
}
put(url: string, res: unknown) {
this.store.set(url, res);
return Promise.resolve();
}
}
let cache: FakeCache;
let deleted: string[];
let requested: { url: string; mode?: string }[];
/** Answers the style URL and the TileJSON as JSON, everything else as a body-less 200. */
function respond(url: string) {
if (url === STYLE_URL) return { ok: true, json: () => Promise.resolve(STYLE_DOC), clone: () => ({}) };
if (url === 'https://tiles.openfreemap.org/planet') {
return { ok: true, json: () => Promise.resolve({ tiles: [TILE_TEMPLATE] }), clone: () => ({}) };
}
return { ok: true, json: () => Promise.resolve({}), clone: () => ({}) };
}
beforeEach(() => {
cache = new FakeCache();
deleted = [];
requested = [];
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true });
vi.stubGlobal('caches', {
open: vi.fn().mockResolvedValue(cache),
delete: vi.fn((name: string) => {
deleted.push(name);
return Promise.resolve(true);
}),
});
vi.stubGlobal(
'fetch',
vi.fn((url: string, init?: { mode?: string }) => {
requested.push({ url, mode: init?.mode });
return Promise.resolve(respond(url));
}),
);
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe('prefetchStyleAssets', () => {
it('FE-SYNC-GLPF-001: takes the tile template from the TileJSON rather than guessing it', async () => {
// OpenFreeMap's tile path carries a planet version. Guessing /planet/{z}/{x}/{y}.pbf
// answers 200 with an empty body, which caches as a map with no data on it.
const template = await prefetchStyleAssets(STYLE_URL);
expect(template).toBe(TILE_TEMPLATE);
expect(cache.store.has('https://tiles.openfreemap.org/planet')).toBe(true);
});
it('FE-SYNC-GLPF-002: warms both sprite densities and both parts', async () => {
await prefetchStyleAssets(STYLE_URL);
const urls = [...cache.store.keys()];
expect(urls).toEqual(
expect.arrayContaining([
`${STYLE_DOC.sprite}.json`,
`${STYLE_DOC.sprite}.png`,
`${STYLE_DOC.sprite}@2x.json`,
`${STYLE_DOC.sprite}@2x.png`,
]),
);
});
it('FE-SYNC-GLPF-003: warms the Latin ranges for each distinct font stack, deduped', async () => {
await prefetchStyleAssets(STYLE_URL);
const glyphs = [...cache.store.keys()].filter(u => u.includes('/fonts/'));
// Two stacks x nine Latin ranges. Asking for all 256 ranges would pull down
// megabytes of CJK that nothing on this map renders.
expect(glyphs).toHaveLength(18);
expect(glyphs.some(u => u.includes('Noto%20Sans%20Regular') && u.endsWith('/0-255.pbf'))).toBe(true);
expect(glyphs.some(u => u.includes('Noto%20Sans%20Italic'))).toBe(true);
expect(glyphs.some(u => u.includes('20480-20735'))).toBe(false);
});
it('FE-SYNC-GLPF-004: asks in cors mode, never no-cors', async () => {
// An opaque response cannot be read, so MapLibre would find a zero-byte body
// in the cache and draw nothing. OpenFreeMap sends the CORS header.
await prefetchStyleAssets(STYLE_URL);
expect(requested.length).toBeGreaterThan(0);
expect(requested.every(r => r.mode === 'cors')).toBe(true);
});
it('FE-SYNC-GLPF-005: gives up quietly when the style cannot be read', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')));
await expect(prefetchStyleAssets(STYLE_URL)).resolves.toBeNull();
});
it('FE-SYNC-GLPF-006: uses a source that already carries its tiles inline', async () => {
const inline = { sources: { s: { tiles: ['https://example.test/{z}/{x}/{y}.pbf'] } } };
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(inline), clone: () => ({}) }),
);
await expect(prefetchStyleAssets(STYLE_URL)).resolves.toBe('https://example.test/{z}/{x}/{y}.pbf');
});
});
describe('prefetchVectorForPlaces', () => {
const places = [buildPlace({ lat: 52.52, lng: 13.405 })];
it('FE-SYNC-GLPF-007: downloads the tiles covering the trip, z0 to z14', async () => {
const result = await prefetchVectorForPlaces(places, STYLE_URL);
expect(result.template).toBe(TILE_TEMPLATE);
expect(result.tiles).toBeGreaterThan(0);
const zooms = new Set(
[...cache.store.keys()]
.filter(u => u.endsWith('.pbf') && u.includes('/planet/'))
.map(u => Number(u.split('/planet/')[1]!.split('/')[1])),
);
// z14 is the last one OpenFreeMap serves; MapLibre scales it beyond that
// rather than asking for another, which is why this stops where raster could not.
// The floor is z0: MapLibre cannot overzoom downward, so the fitBounds view
// of a wide trip needs the low zooms cached too (#2180).
expect([...zooms].sort((a, b) => a - b)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]);
});
it('FE-SYNC-GLPF-008: does nothing offline', async () => {
Object.defineProperty(navigator, 'onLine', { value: false, writable: true, configurable: true });
await expect(prefetchVectorForPlaces(places, STYLE_URL)).resolves.toEqual({ tiles: 0, template: null });
expect(requested).toHaveLength(0);
});
it('FE-SYNC-GLPF-009: does nothing when no place has coordinates', async () => {
const result = await prefetchVectorForPlaces([buildPlace({ lat: null, lng: null })], STYLE_URL);
expect(result).toEqual({ tiles: 0, template: null });
});
it('FE-SYNC-GLPF-010: abandons the queue when cancelled', async () => {
const result = await prefetchVectorForPlaces(places, STYLE_URL, () => true);
// The style assets are already paid for at that point; the tiles are not.
expect(result.tiles).toBe(0);
expect([...cache.store.keys()].some(u => u.endsWith('.pbf') && u.includes('/planet/'))).toBe(false);
});
it('FE-SYNC-GLPF-011: a second run over a warm cache re-downloads nothing', async () => {
await prefetchVectorForPlaces(places, STYLE_URL);
const first = requested.length;
requested.length = 0;
const again = await prefetchVectorForPlaces(places, STYLE_URL);
expect(again.tiles).toBe(0);
expect(requested.length).toBeLessThan(first);
});
});
describe('clearVectorCache', () => {
it('FE-SYNC-GLPF-012: drops the offline vector basemap', async () => {
await clearVectorCache();
expect(deleted).toEqual(['gl-map-offline']);
});
it('FE-SYNC-GLPF-013: stays quiet when storage is unavailable', async () => {
vi.stubGlobal('caches', { delete: vi.fn().mockRejectedValue(new Error('no storage')) });
await expect(clearVectorCache()).resolves.toBeUndefined();
});
});