From 1922251fb338e33da82a3ac609ea6a96c766042e Mon Sep 17 00:00:00 2001 From: jubnl Date: Sat, 11 Jul 2026 18:29:17 +0800 Subject: [PATCH] chore: remove offline XYZ tile prefetcher and cache - Remove tilePrefetcher.ts and its test file - Remove tile prefetch calls from tripSyncManager - Remove map tiles toggle from OfflineTab settings - Remove cacheTiles preference from offlinePrefs - Map tiles offline caching is no longer needed with AMap --- client/src/components/Settings/OfflineTab.tsx | 66 ++--- client/src/sync/offlinePrefs.ts | 19 +- client/src/sync/tilePrefetcher.ts | 233 ---------------- client/src/sync/tripSyncManager.ts | 24 +- client/tests/unit/sync/offlinePrefs.test.ts | 17 +- client/tests/unit/sync/tilePrefetcher.test.ts | 248 ------------------ 6 files changed, 34 insertions(+), 573 deletions(-) delete mode 100644 client/src/sync/tilePrefetcher.ts delete mode 100644 client/tests/unit/sync/tilePrefetcher.test.ts diff --git a/client/src/components/Settings/OfflineTab.tsx b/client/src/components/Settings/OfflineTab.tsx index 40cf63db..9e7a74c6 100644 --- a/client/src/components/Settings/OfflineTab.tsx +++ b/client/src/components/Settings/OfflineTab.tsx @@ -15,10 +15,9 @@ import { offlineDb, clearAll, clearTripData } from '../../db/offlineDb' import { tripsApi } from '../../api/client' import { tripSyncManager, type PrepareProgress } from '../../sync/tripSyncManager' import { mutationQueue } from '../../sync/mutationQueue' -import { clearTileCache } from '../../sync/tilePrefetcher' import { isEffectivelyOffline } from '../../sync/networkMode' import { - getOfflinePrefs, setCacheTiles, setConflictStrategy, + getOfflinePrefs, setConflictStrategy, isTripOfflineEnabled, setTripOfflineEnabled, onOfflinePrefsChange, type ConflictStrategy, } from '../../sync/offlinePrefs' @@ -147,13 +146,6 @@ export default function OfflineTab(): React.ReactElement { } } - async function handleToggleTiles() { - const next = !prefs.cacheTiles - setCacheTiles(next) - // Turning tiles off reclaims the bulk tile storage straight away. - if (!next) await clearTileCache() - } - async function handleToggleTrip(tripId: number) { const next = !isTripOfflineEnabled(tripId) setTripOfflineEnabled(tripId, next) @@ -287,41 +279,29 @@ export default function OfflineTab(): React.ReactElement { )} - {/* What to store offline */} -
-
- } - /> - {allTrips.length > 0 && ( -
-
- {t('settings.offline.storage.tripsTitle')} -
-
- {allTrips.map((trip) => { - const on = isTripOfflineEnabled(trip.id) - return ( -
-
-
- {trip.title} -
-
- {on ? t('settings.offline.storage.tripOn') : t('settings.offline.storage.tripOff')} -
-
- handleToggleTrip(trip.id)} label={trip.title} /> + {/* Per-trip offline toggles */} + {allTrips.length > 0 && ( +
+
+ {allTrips.map((trip) => { + const on = isTripOfflineEnabled(trip.id) + return ( +
+
+
+ {trip.title}
- ) - })} -
-
- )} -
-
+
+ {on ? t('settings.offline.storage.tripOn') : t('settings.offline.storage.tripOff')} +
+
+ handleToggleTrip(trip.id)} label={trip.title} /> +
+ ) + })} +
+
+ )} {/* Cache stats + list + clear */}
diff --git a/client/src/sync/offlinePrefs.ts b/client/src/sync/offlinePrefs.ts index 90bf18e1..c07474b7 100644 --- a/client/src/sync/offlinePrefs.ts +++ b/client/src/sync/offlinePrefs.ts @@ -3,13 +3,10 @@ * HOW sync conflicts are resolved (discussion #1135, asks 2 and 3). * * These live in localStorage rather than the server user-settings because they - * are inherently per-device: how much storage a phone should spend on map tiles, - * or which trips to keep on this particular device, has nothing to do with the - * account and everything to do with the hardware in the user's hand. + * are inherently per-device: how much storage a phone should spend or which + * trips to keep on this particular device has nothing to do with the account + * and everything to do with the hardware in the user's hand. * - * cacheTiles — global on/off for pre-downloading map tiles. Off keeps - * the cache to trip data + documents only ("not the whole - * world map"). See tripSyncManager / clearTileCache. * disabledTripIds — trips the user explicitly excluded from offline storage. * Everything else that is date-eligible is cached. * conflictStrategy — what to do when an offline edit collides with a newer @@ -20,7 +17,6 @@ export type ConflictStrategy = 'ask' | 'mine' | 'server' export interface OfflinePrefs { - cacheTiles: boolean disabledTripIds: number[] conflictStrategy: ConflictStrategy } @@ -28,7 +24,6 @@ export interface OfflinePrefs { const STORAGE_KEY = 'trek_offline_prefs' const DEFAULTS: OfflinePrefs = { - cacheTiles: true, disabledTripIds: [], conflictStrategy: 'ask', } @@ -40,9 +35,8 @@ function read(): OfflinePrefs { try { const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(STORAGE_KEY) : null if (!raw) return { ...DEFAULTS } - const parsed = JSON.parse(raw) as Partial + const parsed = JSON.parse(raw) as Partial return { - cacheTiles: typeof parsed.cacheTiles === 'boolean' ? parsed.cacheTiles : DEFAULTS.cacheTiles, disabledTripIds: Array.isArray(parsed.disabledTripIds) ? parsed.disabledTripIds.filter(n => typeof n === 'number') : [], conflictStrategy: parsed.conflictStrategy === 'mine' || parsed.conflictStrategy === 'server' ? parsed.conflictStrategy : 'ask', } @@ -62,11 +56,6 @@ export function getOfflinePrefs(): OfflinePrefs { return { ..._prefs, disabledTripIds: [..._prefs.disabledTripIds] } } -export function setCacheTiles(on: boolean): void { - if (_prefs.cacheTiles === on) return - write({ ..._prefs, cacheTiles: on }) -} - export function setConflictStrategy(strategy: ConflictStrategy): void { if (_prefs.conflictStrategy === strategy) return write({ ..._prefs, conflictStrategy: strategy }) diff --git a/client/src/sync/tilePrefetcher.ts b/client/src/sync/tilePrefetcher.ts deleted file mode 100644 index f4022392..00000000 --- a/client/src/sync/tilePrefetcher.ts +++ /dev/null @@ -1,233 +0,0 @@ -/** - * Map tile prefetcher — warms the Workbox 'map-tiles' cache for a trip's - * bounding box so maps render offline. - * - * Algorithm: - * 1. Compute bbox from trip's place coordinates + padding. - * 2. For zooms 10–16, enumerate tile XYZ coordinates within bbox. - * 3. Stop when cumulative tile estimate exceeds MAX_TILES (~50 MB). - * 4. Fetch each tile URL so the Service Worker CacheFirst handler caches it. - * - * Tile URL template format: Leaflet-compatible {z}/{x}/{y} with optional - * {s} (subdomain) and {r} (retina suffix). - */ - -import type { Place } from '../types' -import { offlineDb, upsertSyncMeta } from '../db/offlineDb' - -// ── Constants ───────────────────────────────────────────────────────────────── - -/** Estimated average tile size in KB (raster basemap tiles ~15 KB). */ -const AVG_TILE_KB = 15 - -/** - * Hard cap on prefetched tiles (~180 MB). - * - * MUST stay in sync with the Workbox 'map-tiles' `maxEntries` in - * client/vite.config.js (kept equal). If this budget exceeds the SW cache size, - * the LRU evicts freshly-prefetched tiles on arrival and the offline map goes - * blank — which is exactly the bug this value was raised (from ~3413) to fix. - */ -export const MAX_TILES = Math.floor((180 * 1024) / AVG_TILE_KB) // = 12288 - -const DEFAULT_TILE_URL = - 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' - -const SUBDOMAINS = ['a', 'b', 'c', 'd'] -let _subIdx = 0 -function nextSubdomain(): string { - return SUBDOMAINS[_subIdx++ % SUBDOMAINS.length] -} - -// ── Tile math ────────────────────────────────────────────────────────────────── - -/** Longitude → tile X at given zoom. */ -export function lngToTileX(lng: number, zoom: number): number { - return Math.floor(((lng + 180) / 360) * Math.pow(2, zoom)) -} - -/** Latitude → tile Y at given zoom (Web Mercator, y increases southward). */ -export function latToTileY(lat: number, zoom: number): number { - const n = Math.pow(2, zoom) - const latRad = (lat * Math.PI) / 180 - return Math.floor( - ((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) * n, - ) -} - -/** Expand a single-point bbox to min 0.1° span (~10 km) in each axis. */ -function ensureMinSpan(min: number, max: number, minSpan = 0.1): [number, number] { - if (max - min < minSpan) { - const mid = (min + max) / 2 - return [mid - minSpan / 2, mid + minSpan / 2] - } - return [min, max] -} - -// ── Public types ────────────────────────────────────────────────────────────── - -export interface TileBbox { - minLat: number - maxLat: number - minLng: number - maxLng: number -} - -// ── Core logic ──────────────────────────────────────────────────────────────── - -/** - * Compute the bounding box for a list of places with optional padding. - * Returns null if no places have coordinates. - */ -export function computeBbox(places: Place[], paddingFraction = 0.1): TileBbox | null { - const valid = places.filter(p => p.lat !== null && p.lng !== null) - if (valid.length === 0) return null - - const lats = valid.map(p => p.lat as number) - const lngs = valid.map(p => p.lng as number) - - const [rawMinLat, rawMaxLat] = ensureMinSpan(Math.min(...lats), Math.max(...lats)) - const [rawMinLng, rawMaxLng] = ensureMinSpan(Math.min(...lngs), Math.max(...lngs)) - - const latPad = (rawMaxLat - rawMinLat) * paddingFraction - const lngPad = (rawMaxLng - rawMinLng) * paddingFraction - - return { - minLat: Math.max(-85.0511, rawMinLat - latPad), - maxLat: Math.min(85.0511, rawMaxLat + latPad), - minLng: Math.max(-180, rawMinLng - lngPad), - maxLng: Math.min(180, rawMaxLng + lngPad), - } -} - -/** - * Count tiles that would be fetched across the zoom range for a bbox. - * Used to enforce the size guard without actually fetching. - */ -export function countTiles(bbox: TileBbox, minZoom: number, maxZoom: number): number { - let total = 0 - for (let z = minZoom; z <= maxZoom; z++) { - const minX = lngToTileX(bbox.minLng, z) - const maxX = lngToTileX(bbox.maxLng, z) - const minY = latToTileY(bbox.maxLat, z) // northern edge → smaller y - const maxY = latToTileY(bbox.minLat, z) // southern edge → larger y - total += (maxX - minX + 1) * (maxY - minY + 1) - if (total > MAX_TILES) return total - } - return total -} - -/** - * Build the concrete tile URL for given z/x/y from a Leaflet template. - * Rotates through subdomains (a–d). - */ -export function buildTileUrl(template: string, z: number, x: number, y: number): string { - return template - .replace('{z}', String(z)) - .replace('{x}', String(x)) - .replace('{y}', String(y)) - .replace('{s}', nextSubdomain()) - .replace('{r}', '') -} - -/** - * Prefetch tiles for a bbox into the Service Worker cache. - * Stops at the zoom level where the size cap would be exceeded. - * No-ops when: - * - offline - * - no active Service Worker (tiles won't be cached anyway) - * - total tile count exceeds MAX_TILES before even starting zoom 10 - */ -export async function prefetchTiles( - bbox: TileBbox, - tileUrlTemplate: string, - minZoom = 10, - maxZoom = 16, - awaitAll = false, -): Promise { - if (!navigator.onLine) return 0 - if (!('serviceWorker' in navigator) || !navigator.serviceWorker.controller) return 0 - - let fetched = 0 - // When awaitAll is set (the "prepare for offline" path), we wait for every tile - // request to settle so the caller's progress bar only completes once the tiles - // are actually downloaded into the SW cache — not merely dispatched. - const inflight: Promise[] = [] - - for (let z = minZoom; z <= maxZoom; z++) { - const minX = lngToTileX(bbox.minLng, z) - const maxX = lngToTileX(bbox.maxLng, z) - const minY = latToTileY(bbox.maxLat, z) - const maxY = latToTileY(bbox.minLat, z) - const count = (maxX - minX + 1) * (maxY - minY + 1) - - if (fetched + count > MAX_TILES) break - - for (let x = minX; x <= maxX; x++) { - for (let y = minY; y <= maxY; y++) { - const url = buildTileUrl(tileUrlTemplate, z, x, y) - // SW CacheFirst handler stores the response. Fire-and-forget unless the - // caller asked to await completion. - const p = fetch(url, { mode: 'no-cors' }).catch(() => {}) - if (awaitAll) inflight.push(p) - fetched++ - } - } - } - - if (awaitAll && inflight.length) await Promise.allSettled(inflight) - return fetched -} - -/** - * Drop the pre-downloaded map-tile cache. Called when the user turns off - * "store map tiles offline" (#1135 ask 2) so the bulk tile storage — the real - * "whole world map" concern — is reclaimed immediately. - */ -export async function clearTileCache(): Promise { - try { - if (typeof caches !== 'undefined') await caches.delete('map-tiles') - } catch { - /* Cache Storage unavailable (no SW / private mode) — nothing to clear */ - } -} - -/** - * Full pipeline: compute bbox → guard → prefetch → update syncMeta. - * Designed to be called fire-and-forget from tripSyncManager. - */ -export async function prefetchTilesForTrip( - tripId: number, - places: Place[], - tileUrlTemplate?: string, - awaitAll = false, -): Promise { - const template = tileUrlTemplate || DEFAULT_TILE_URL - const bbox = computeBbox(places) - if (!bbox) return - - // Zoom-clamp rather than skip: prefetchTiles fills zooms low→high and stops - // once MAX_TILES is reached, so large (region / road-trip) bboxes still get - // their lower zooms cached instead of being skipped entirely. - // - // NOTE: opaque (no-cors) tile responses are padded by Chromium to ~7 MB each - // for quota accounting, so the real on-disk budget is far below 180 MB. We - // keep no-cors deliberately: switching to cors would break self-hosted/custom - // tile providers that don't send CORS headers. To stop the browser evicting - // these tiles under the inflated quota, we request persistent storage at app - // init instead (sync/persistentStorage.ts). - const fetched = await prefetchTiles(bbox, template, 10, 16, awaitAll) - - // Update syncMeta with bbox and tile count - const meta = await offlineDb.syncMeta.get(tripId) - if (meta) { - await upsertSyncMeta({ - ...meta, - tilesBbox: [bbox.minLng, bbox.minLat, bbox.maxLng, bbox.maxLat], - }) - } - - if (fetched > 0) { - console.info(`[tilePrefetch] trip ${tripId}: queued ${fetched} tiles for caching`) - } -} diff --git a/client/src/sync/tripSyncManager.ts b/client/src/sync/tripSyncManager.ts index 5a9c6d71..2257ab70 100644 --- a/client/src/sync/tripSyncManager.ts +++ b/client/src/sync/tripSyncManager.ts @@ -29,10 +29,8 @@ import { clearTripData, enforceBlobBudget, } from '../db/offlineDb' -import { prefetchTilesForTrip } from './tilePrefetcher' import { isAuthed } from './authGate' -import { getOfflinePrefs, isTripOfflineEnabled } from './offlinePrefs' -import { useSettingsStore } from '../store/settingsStore' +import { isTripOfflineEnabled } from './offlinePrefs' import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation, TripFile, Accommodation, TripMember } from '../types' // ── Types ───────────────────────────────────────────────────────────────────── @@ -189,17 +187,10 @@ export const tripSyncManager = { tagsApi.list().then(d => upsertTags(d.tags)).catch(() => {}) categoriesApi.list().then(d => upsertCategories(d.categories)).catch(() => {}) - // Cache file blobs + map tiles in background (don't block syncAll) - const cacheTiles = getOfflinePrefs().cacheTiles - const tileUrl = useSettingsStore.getState().settings.map_tile_url || undefined + // Cache file blobs in background (don't block syncAll) for (const trip of toSync) { const files = await offlineDb.tripFiles.where('trip_id').equals(trip.id).toArray() cacheFilesForTrip(files).catch(console.error) - - if (cacheTiles) { - const places = await offlineDb.places.where('trip_id').equals(trip.id).toArray() - prefetchTilesForTrip(trip.id, places, tileUrl).catch(console.error) - } } } finally { _syncing = false @@ -248,17 +239,6 @@ export const tripSyncManager = { await cacheFilesForTrip(files).catch(console.error) } - // 3) Map tiles — awaited, and only when the user opted to store them. - if (getOfflinePrefs().cacheTiles) { - const tileUrl = useSettingsStore.getState().settings.map_tile_url || undefined - i = 0 - for (const trip of toSync) { - onProgress?.({ phase: 'tiles', current: ++i, total, label: trip.title }) - const places = await offlineDb.places.where('trip_id').equals(trip.id).toArray() - await prefetchTilesForTrip(trip.id, places, tileUrl, true).catch(console.error) - } - } - onProgress?.({ phase: 'done', current: total, total }) return total } finally { diff --git a/client/tests/unit/sync/offlinePrefs.test.ts b/client/tests/unit/sync/offlinePrefs.test.ts index 66f83f80..e7aaad27 100644 --- a/client/tests/unit/sync/offlinePrefs.test.ts +++ b/client/tests/unit/sync/offlinePrefs.test.ts @@ -1,9 +1,9 @@ /** - * offlinePrefs unit tests — device-local "what to store offline" + conflict strategy. + * offlinePrefs unit tests — device-local trip offline settings + conflict strategy. */ import { describe, it, expect, beforeEach } from 'vitest' import { - getOfflinePrefs, setCacheTiles, setConflictStrategy, + getOfflinePrefs, setConflictStrategy, isTripOfflineEnabled, setTripOfflineEnabled, onOfflinePrefsChange, _resetOfflinePrefs, } from '../../../src/sync/offlinePrefs' @@ -13,20 +13,13 @@ beforeEach(() => { }) describe('offlinePrefs', () => { - it('defaults to tiles on, no disabled trips, ask strategy', () => { + it('defaults to no disabled trips and ask strategy', () => { const p = getOfflinePrefs() - expect(p.cacheTiles).toBe(true) expect(p.disabledTripIds).toEqual([]) expect(p.conflictStrategy).toBe('ask') expect(isTripOfflineEnabled(5)).toBe(true) }) - it('toggles tile caching and persists it', () => { - setCacheTiles(false) - expect(getOfflinePrefs().cacheTiles).toBe(false) - expect(JSON.parse(localStorage.getItem('trek_offline_prefs')!).cacheTiles).toBe(false) - }) - it('disables and re-enables a single trip', () => { setTripOfflineEnabled(7, false) expect(isTripOfflineEnabled(7)).toBe(false) @@ -51,10 +44,10 @@ describe('offlinePrefs', () => { it('notifies subscribers and stops after unsubscribe', () => { let n = 0 const unsub = onOfflinePrefsChange(() => { n++ }) - setCacheTiles(false) + setConflictStrategy('mine') expect(n).toBe(1) unsub() - setCacheTiles(true) + setConflictStrategy('server') expect(n).toBe(1) }) }) diff --git a/client/tests/unit/sync/tilePrefetcher.test.ts b/client/tests/unit/sync/tilePrefetcher.test.ts deleted file mode 100644 index 76828385..00000000 --- a/client/tests/unit/sync/tilePrefetcher.test.ts +++ /dev/null @@ -1,248 +0,0 @@ -/** - * tilePrefetcher unit tests. - * - * Covers: bbox computation, tile math, URL building, size guard, - * offline/no-SW guard, syncMeta update. - */ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import 'fake-indexeddb/auto'; -import { - computeBbox, - lngToTileX, - latToTileY, - buildTileUrl, - countTiles, - prefetchTiles, - prefetchTilesForTrip, - MAX_TILES, - type TileBbox, -} from '../../../src/sync/tilePrefetcher'; -import { offlineDb, clearAll, upsertSyncMeta } from '../../../src/db/offlineDb'; -import { buildPlace } from '../../helpers/factories'; - -beforeEach(async () => { - await clearAll(); - Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true }); - // Stub fetch + serviceWorker so prefetch path is exercised - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true })); - Object.defineProperty(navigator, 'serviceWorker', { - value: { controller: {} }, - writable: true, - configurable: true, - }); -}); - -afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); - -// ── bbox computation ────────────────────────────────────────────────────────── - -describe('computeBbox', () => { - it('returns null when no places have coordinates', () => { - const places = [buildPlace({ lat: null, lng: null })]; - expect(computeBbox(places)).toBeNull(); - }); - - it('expands single-point bbox to at least 0.1° span', () => { - const place = buildPlace({ lat: 48.8566, lng: 2.3522 }); - const bbox = computeBbox([place])!; - expect(bbox.maxLat - bbox.minLat).toBeGreaterThan(0.09); - expect(bbox.maxLng - bbox.minLng).toBeGreaterThan(0.09); - }); - - it('computes multi-point bbox with padding', () => { - const places = [ - buildPlace({ lat: 48.8566, lng: 2.3522 }), // Paris - buildPlace({ lat: 51.5074, lng: -0.1278 }), // London - ]; - const bbox = computeBbox(places, 0.1)!; - // Padded bbox should extend beyond raw points - expect(bbox.minLat).toBeLessThan(48.8566); - expect(bbox.maxLat).toBeGreaterThan(51.5074); - expect(bbox.minLng).toBeLessThan(-0.1278); - expect(bbox.maxLng).toBeGreaterThan(2.3522); - }); - - it('clamps to valid Mercator lat bounds', () => { - const places = [buildPlace({ lat: 85.0, lng: 0 })]; - const bbox = computeBbox(places, 0.5)!; - expect(bbox.maxLat).toBeLessThanOrEqual(85.0511); - }); -}); - -// ── tile math ───────────────────────────────────────────────────────────────── - -describe('lngToTileX', () => { - it('returns 0 for lng=-180 at any zoom', () => { - expect(lngToTileX(-180, 10)).toBe(0); - }); - - it('returns max tile for lng=180 at zoom 1', () => { - // At zoom 1: 2^1 = 2 tiles, lng=180 → x = floor(360/360 * 2) = floor(2) = 2 - // But tile range is 0..1, so this is the "overflow" edge — that's fine - expect(lngToTileX(180, 1)).toBe(2); - }); - - it('increases with more easterly longitude', () => { - const x1 = lngToTileX(0, 10); - const x2 = lngToTileX(10, 10); - expect(x2).toBeGreaterThan(x1); - }); -}); - -describe('latToTileY', () => { - it('returns smaller y for higher latitude (north = top)', () => { - const yNorth = latToTileY(60, 10); - const ySouth = latToTileY(10, 10); - expect(yNorth).toBeLessThan(ySouth); - }); - - it('equator is roughly half the tile grid', () => { - const yEq = latToTileY(0, 1); - // zoom 1 → 2 rows, equator ≈ row 1 - expect(yEq).toBe(1); - }); -}); - -// ── URL building ─────────────────────────────────────────────────────────────── - -describe('buildTileUrl', () => { - it('replaces {z}, {x}, {y}, {r} correctly', () => { - const tmpl = 'https://tile.example.com/{z}/{x}/{y}.png'; - const url = buildTileUrl(tmpl, 10, 500, 300); - expect(url).toBe('https://tile.example.com/10/500/300.png'); - }); - - it('replaces {s} with a subdomain character', () => { - const tmpl = 'https://{s}.tiles.example.com/{z}/{x}/{y}.png'; - const url = buildTileUrl(tmpl, 10, 0, 0); - expect(url).toMatch(/^https:\/\/[abcd]\.tiles\.example\.com\/10\/0\/0\.png$/); - }); - - it('removes {r} (retina placeholder)', () => { - const tmpl = 'https://tiles.example.com/{z}/{x}/{y}{r}.png'; - const url = buildTileUrl(tmpl, 10, 0, 0); - expect(url).toBe('https://tiles.example.com/10/0/0.png'); - }); -}); - -// ── countTiles ──────────────────────────────────────────────────────────────── - -describe('countTiles', () => { - it('returns more tiles at higher zoom levels', () => { - const bbox: TileBbox = { minLat: 48.7, maxLat: 49.0, minLng: 2.2, maxLng: 2.5 }; - const low = countTiles(bbox, 10, 10); - const high = countTiles(bbox, 12, 12); - expect(high).toBeGreaterThan(low); - }); - - it('stops counting after exceeding MAX_TILES', () => { - // Very large bbox — should hit cap quickly at high zooms - const bbox: TileBbox = { minLat: -60, maxLat: 60, minLng: -180, maxLng: 180 }; - const count = countTiles(bbox, 10, 16); - expect(count).toBeGreaterThan(MAX_TILES); - }); -}); - -// ── prefetchTiles guards ─────────────────────────────────────────────────────── - -describe('prefetchTiles — offline guard', () => { - it('returns 0 and does not fetch when offline', async () => { - Object.defineProperty(navigator, 'onLine', { value: false }); - const bbox: TileBbox = { minLat: 48.8, maxLat: 48.9, minLng: 2.3, maxLng: 2.4 }; - const count = await prefetchTiles(bbox, 'https://{s}.example.com/{z}/{x}/{y}.png', 10, 10); - expect(count).toBe(0); - expect(vi.mocked(fetch)).not.toHaveBeenCalled(); - }); - - it('returns 0 when no service worker controller', async () => { - Object.defineProperty(navigator, 'serviceWorker', { - value: { controller: null }, - configurable: true, - }); - const bbox: TileBbox = { minLat: 48.8, maxLat: 48.9, minLng: 2.3, maxLng: 2.4 }; - const count = await prefetchTiles(bbox, 'https://{s}.example.com/{z}/{x}/{y}.png', 10, 10); - expect(count).toBe(0); - }); -}); - -describe('prefetchTiles — normal operation', () => { - it('fetches tiles and returns count', async () => { - const bbox: TileBbox = { minLat: 48.84, maxLat: 48.87, minLng: 2.33, maxLng: 2.37 }; - const count = await prefetchTiles(bbox, 'https://{s}.example.com/{z}/{x}/{y}.png', 10, 11); - expect(count).toBeGreaterThan(0); - expect(vi.mocked(fetch)).toHaveBeenCalled(); - }); - - it('stops at zoom level where cap is exceeded', async () => { - // Use a very small MAX_TILES override by using a huge bbox - const bbox: TileBbox = { minLat: -80, maxLat: 80, minLng: -170, maxLng: 170 }; - // This bbox at zoom 10 alone has thousands of tiles — should trigger early stop - const count = await prefetchTiles(bbox, 'https://{s}.example.com/{z}/{x}/{y}.png', 10, 16); - expect(count).toBeLessThanOrEqual(MAX_TILES); - }); -}); - -// ── prefetchTilesForTrip ────────────────────────────────────────────────────── - -describe('prefetchTilesForTrip', () => { - it('no-ops when no places have coordinates', async () => { - const places = [buildPlace({ lat: null, lng: null })]; - await prefetchTilesForTrip(1, places); - expect(vi.mocked(fetch)).not.toHaveBeenCalled(); - }); - - it('updates syncMeta tilesBbox after prefetch', async () => { - await upsertSyncMeta({ tripId: 1, lastSyncedAt: Date.now(), status: 'idle', tilesBbox: null, filesCachedCount: 0 }); - - const places = [ - buildPlace({ trip_id: 1, lat: 48.8566, lng: 2.3522 }), - ]; - await prefetchTilesForTrip(1, places, 'https://{s}.example.com/{z}/{x}/{y}.png'); - - const meta = await offlineDb.syncMeta.get(1); - expect(meta!.tilesBbox).not.toBeNull(); - expect(meta!.tilesBbox).toHaveLength(4); - }); - - it('zoom-clamps instead of skipping when the bbox exceeds MAX_TILES', async () => { - await upsertSyncMeta({ tripId: 1, lastSyncedAt: Date.now(), status: 'idle', tilesBbox: null, filesCachedCount: 0 }); - - // ~4° road-trip span: low zooms fit the budget, high zooms (z14+) blow past - // it. The old guard skipped the whole trip; now we keep what fits. - const places = [ - buildPlace({ trip_id: 1, lat: 45.0, lng: 0.0 }), - buildPlace({ trip_id: 1, lat: 49.0, lng: 4.0 }), - ]; - await prefetchTilesForTrip(1, places, 'https://{s}.example.com/{z}/{x}/{y}.png'); - - // Previously this skipped entirely; now it prefetches a clamped subset. - const calls = vi.mocked(fetch).mock.calls.length; - expect(calls).toBeGreaterThan(0); - expect(calls).toBeLessThanOrEqual(MAX_TILES); - }); - - it('prefetches a region-sized (0.5°) trip that the old all-or-nothing guard would have skipped', async () => { - await upsertSyncMeta({ tripId: 1, lastSyncedAt: Date.now(), status: 'idle', tilesBbox: null, filesCachedCount: 0 }); - - const places = [ - buildPlace({ trip_id: 1, lat: 48.6, lng: 2.1 }), - buildPlace({ trip_id: 1, lat: 49.1, lng: 2.6 }), - ]; - await prefetchTilesForTrip(1, places, 'https://{s}.example.com/{z}/{x}/{y}.png'); - - const calls = vi.mocked(fetch).mock.calls.length; - expect(calls).toBeGreaterThan(0); - expect(calls).toBeLessThanOrEqual(MAX_TILES); - }); -}); - -// ── cap coherence ─────────────────────────────────────────────────────────────── - -describe('MAX_TILES budget', () => { - it('matches the Workbox map-tiles maxEntries in vite.config.js (drift guard)', () => { - expect(MAX_TILES).toBe(12288); - }); -});