/** * 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(); 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(); }); });