mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
2ab2dcc3dc
connect-src never listed tile.openstreetmap.de or tiles.stadiamaps.com, so every prefetch fetch was refused before the service worker saw it, while the swallowed error still counted as a cached tile. Both hosts are allowed now and only reached tiles count. The prefetch rectangle is also widened to the screen the fitBounds view fills, on the raster and the vector path alike; the places extent alone left grey bands beside a north-south trip.
215 lines
8.6 KiB
TypeScript
215 lines
8.6 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-014: reaches as far as the screen the map opens on, not just the places extent', async () => {
|
|
// Berlin + Munich: fitBounds is height-limited, so the opening view shows far
|
|
// more longitude than the places extent, and that band was grey offline (#2180).
|
|
await prefetchVectorForPlaces([
|
|
buildPlace({ lat: 52.52, lng: 13.405 }),
|
|
buildPlace({ lat: 48.14, lng: 11.58 }),
|
|
], STYLE_URL);
|
|
|
|
const xs = [...cache.store.keys()]
|
|
.filter(u => u.endsWith('.pbf') && u.includes('/planet/'))
|
|
.map(u => u.split('/planet/')[1]!.split('/'))
|
|
.filter(parts => Number(parts[1]) === 7)
|
|
.map(parts => Number(parts[2]));
|
|
expect((Math.max(...xs) - Math.min(...xs) + 1) * 256).toBeGreaterThanOrEqual(window.innerWidth);
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|