chore: remove offline XYZ tile prefetcher and cache
Some checks are pending
Security Scan / scout (push) Waiting to run
Some checks are pending
Security Scan / scout (push) Waiting to run
- 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
This commit is contained in:
parent
a956a06792
commit
1922251fb3
@ -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,19 +279,9 @@ export default function OfflineTab(): React.ReactElement {
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* What to store offline */}
|
||||
<Section title={t('settings.offline.storage.title')} icon={MapIcon}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<Row
|
||||
label={t('settings.offline.storage.tiles')}
|
||||
hint={t('settings.offline.storage.tilesHint')}
|
||||
control={<ToggleSwitch on={prefs.cacheTiles} onToggle={handleToggleTiles} label={t('settings.offline.storage.tiles')} />}
|
||||
/>
|
||||
{/* Per-trip offline toggles */}
|
||||
{allTrips.length > 0 && (
|
||||
<div style={{ borderTop: '1px solid var(--border-secondary, #e5e7eb)', paddingTop: 16 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 'calc(13px * var(--fs-scale-body, 1))', marginBottom: 8 }} className="text-content">
|
||||
{t('settings.offline.storage.tripsTitle')}
|
||||
</div>
|
||||
<Section title={t('settings.offline.storage.title')} icon={MapIcon}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{allTrips.map((trip) => {
|
||||
const on = isTripOfflineEnabled(trip.id)
|
||||
@ -318,10 +300,8 @@ export default function OfflineTab(): React.ReactElement {
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Cache stats + list + clear */}
|
||||
<Section title={t('settings.offline.cache.title')} icon={Database}>
|
||||
|
||||
@ -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<OfflinePrefs>
|
||||
const parsed = JSON.parse(raw) as Partial<OfflinePrefs & { cacheTiles?: boolean }>
|
||||
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 })
|
||||
|
||||
@ -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<number> {
|
||||
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<unknown>[] = []
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
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`)
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user