feat(map): update map functionality with OpenFreeMap support and refactor tile caching logic

This commit is contained in:
Ivan
2026-04-22 20:29:26 -05:00
parent bf5ad24733
commit 719e1634aa
6 changed files with 354 additions and 142 deletions

View File

@@ -377,9 +377,10 @@
>
<div class="h-px flex-1 bg-gray-100 dark:bg-zinc-800 ml-3"></div>
</div>
<div class="grid grid-cols-4 gap-1">
<div class="grid grid-cols-5 gap-1">
<button
v-for="style in [
{ id: 'openfreemap', label: 'OFM' },
{ id: 'osm', label: 'OSM' },
{ id: 'carto-dark', label: 'Dark' },
{ id: 'carto-voyager', label: 'Voy' },
@@ -388,6 +389,8 @@
:key="style.id"
class="py-1.5 text-[8px] font-bold uppercase rounded-md transition-all border leading-tight"
:class="
(style.id === 'openfreemap' &&
tileServerUrl.includes('tiles.openfreemap.org/styles/')) ||
(style.id === 'osm' && tileServerUrl.includes('openstreetmap.org')) ||
(style.id === 'carto-dark' &&
tileServerUrl.includes('basemaps.cartocdn.com/dark_all')) ||
@@ -877,11 +880,14 @@
<script>
import "ol/ol.css";
import { apply as applyMapboxStyle } from "ol-mapbox-style";
import Map from "ol/Map";
import View from "ol/View";
import LayerGroup from "ol/layer/Group";
import TileLayer from "ol/layer/Tile";
import VectorLayer from "ol/layer/Vector";
import XYZ from "ol/source/XYZ";
import TileState from "ol/TileState";
import VectorSource from "ol/source/Vector";
import Feature from "ol/Feature";
import Point from "ol/geom/Point";
@@ -933,6 +939,9 @@ import MapExportProgressPanel from "./internal/MapExportProgressPanel.vue";
import MapNoMapWarning from "./internal/MapNoMapWarning.vue";
import MapLoadingOverlay from "./internal/MapLoadingOverlay.vue";
const OPENFREEMAP_DEFAULT_STYLE = "https://tiles.openfreemap.org/styles/bright";
const LEGACY_DEFAULT_OSM_RASTER = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
export default {
name: "MapPage",
components: {
@@ -981,7 +990,7 @@ export default {
cachingEnabled: true,
// tile server
tileServerUrl: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
tileServerUrl: OPENFREEMAP_DEFAULT_STYLE,
// search
searchQuery: "",
@@ -1136,7 +1145,7 @@ export default {
console.warn("Failed to load map state from cache", e);
}
this.initMap();
await this.initMap();
if (this.telemetryList.length > 0) {
this.updateMarkers();
@@ -1251,6 +1260,14 @@ export default {
document.removeEventListener("click", this.handleClickOutside);
window.removeEventListener("resize", this.checkScreenSize);
WebSocketConnection.off("message", this.onWebsocketMessage);
if (this._pointerMoveRaf != null) {
cancelAnimationFrame(this._pointerMoveRaf);
this._pointerMoveRaf = null;
}
if (this.map) {
this.map.setTarget(null);
this.map = null;
}
},
methods: {
saveMapState() {
@@ -1360,17 +1377,7 @@ export default {
ToastUtils.error(this.$t("map.failed_save_storage"));
}
},
initMap() {
// Patch canvas getContext to address performance warning
const originalGetContext = HTMLCanvasElement.prototype.getContext;
HTMLCanvasElement.prototype.getContext = function (type, attributes) {
if (type === "2d") {
attributes = attributes || {};
attributes.willReadFrequently = true;
}
return originalGetContext.call(this, type, attributes);
};
async initMap() {
const defaultLat = parseFloat(this.config?.map_default_lat || 0);
const defaultLon = parseFloat(this.config?.map_default_lon || 0);
const defaultZoom = parseInt(this.config?.map_default_zoom || 2);
@@ -1382,13 +1389,11 @@ export default {
: fromLonLat([defaultLon, defaultLat]);
const startZoom = this.currentZoom !== 2 ? this.currentZoom : defaultZoom;
const baseLayer = await this.buildBaseMapLayer();
const mapPixelRatio = Math.min(typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1, 2);
this.map = new Map({
target: this.$refs.mapContainer,
layers: [
new TileLayer({
source: this.getTileSource(),
}),
],
layers: [baseLayer],
view: new View({
center: startCenter,
zoom: startZoom,
@@ -1397,6 +1402,8 @@ export default {
attribution: false,
rotate: false,
}),
pixelRatio: mapPixelRatio,
maxTilesLoading: 48,
});
// setup drawing layer
@@ -1648,6 +1655,7 @@ export default {
if (!url) return false;
const onlinePatterns = [
"openstreetmap.org",
"openfreemap.org",
"cartocdn.com",
"thunderforest.com",
"stamen.com",
@@ -1684,9 +1692,49 @@ export default {
return false;
}
},
isOpenFreeMapStyleUrl(url) {
return typeof url === "string" && url.includes("tiles.openfreemap.org/styles/");
},
async buildBaseMapLayer() {
const url = (this.tileServerUrl || OPENFREEMAP_DEFAULT_STYLE).trim();
if (!this.offlineEnabled && this.isOpenFreeMapStyleUrl(url)) {
const group = new LayerGroup();
await applyMapboxStyle(group, url);
return group;
}
return new TileLayer({
source: this.getTileSource(),
preload: 2,
transition: 0,
cacheSize: 896,
});
},
/**
* Decode tile blobs off the critical path where possible (createImageBitmap)
* and avoid object URLs when bitmap path succeeds.
*/
async fastApplyBlobToTile(tile, blob) {
if (typeof createImageBitmap === "function") {
try {
const bitmap = await createImageBitmap(blob);
tile.setImage(bitmap);
return;
} catch {
/* fall through */
}
}
const el = tile.getImage();
if (el && "src" in el) {
const url = URL.createObjectURL(blob);
el.src = url;
setTimeout(() => URL.revokeObjectURL(url), 10000);
return;
}
tile.setState(TileState.ERROR);
},
getTileSource() {
const isOffline = this.offlineEnabled;
const defaultTileUrl = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
const defaultTileUrl = OPENFREEMAP_DEFAULT_STYLE;
const customTileUrl = this.tileServerUrl || defaultTileUrl;
const isCustomLocal = this.isLocalUrl(customTileUrl);
const isDefaultOnline = this.isDefaultOnlineUrl(customTileUrl);
@@ -1699,7 +1747,7 @@ export default {
} else if (isCustomLocal) {
// It's a local/mesh URL, allow it
tileUrl = customTileUrl;
} else if (customTileUrl !== defaultTileUrl) {
} else if (customTileUrl !== defaultTileUrl && customTileUrl !== LEGACY_DEFAULT_OSM_RASTER) {
// It's a custom URL that isn't a known online one,
// assume it might be a local mesh server with a domain.
tileUrl = customTileUrl;
@@ -1714,6 +1762,7 @@ export default {
const source = new XYZ({
url: tileUrl,
crossOrigin: "anonymous",
transition: 0,
});
// Track tile load errors to notify user if they appear to be offline
@@ -1742,18 +1791,15 @@ export default {
const response = await fetch(src);
if (!response.ok) {
if (response.status === 404) {
tile.setState(3);
tile.setState(TileState.ERROR);
return;
}
throw new Error(`HTTP ${response.status}`);
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
tile.getImage().src = url;
// Cleanup to prevent memory leaks
setTimeout(() => URL.revokeObjectURL(url), 10000);
await this.fastApplyBlobToTile(tile, blob);
} catch {
tile.setState(3);
tile.setState(TileState.ERROR);
}
});
} else {
@@ -1766,9 +1812,7 @@ export default {
try {
const cached = await TileCache.getTile(src);
if (cached) {
const url = URL.createObjectURL(cached);
tile.getImage().src = url;
setTimeout(() => URL.revokeObjectURL(url), 10000);
await this.fastApplyBlobToTile(tile, cached);
return;
}
@@ -1777,12 +1821,11 @@ export default {
throw new Error(`HTTP ${response.status}`);
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
tile.getImage().src = url;
setTimeout(() => URL.revokeObjectURL(url), 10000);
await this.fastApplyBlobToTile(tile, blob);
// Background cache write to avoid blocking UI
TileCache.setTile(src, blob).catch(() => {});
queueMicrotask(() => {
TileCache.setTile(src, blob).catch(() => {});
});
} catch {
originalTileLoadFunction(tile, src);
}
@@ -1799,14 +1842,14 @@ export default {
this.hasOfflineMap = true;
if (this.offlineEnabled) {
this.updateMapSource();
await this.updateMapSource();
}
} else {
this.hasOfflineMap = false;
this.metadata = null;
if (this.offlineEnabled) {
this.offlineEnabled = false;
this.updateMapSource();
await this.updateMapSource();
}
}
} catch {
@@ -1814,11 +1857,11 @@ export default {
this.metadata = null;
if (this.offlineEnabled) {
this.offlineEnabled = false;
this.updateMapSource();
await this.updateMapSource();
}
}
},
updateMapSource() {
async updateMapSource() {
if (!this.map) return;
const layers = this.map.getLayers();
@@ -1826,12 +1869,7 @@ export default {
// or just clear and re-add everything correctly
layers.clear();
// 1. Tile layer
layers.push(
new TileLayer({
source: this.getTileSource(),
})
);
layers.push(await this.buildBaseMapLayer());
// 2. Draw layer
if (this.drawLayer) {
@@ -1853,12 +1891,15 @@ export default {
this.showOfflineHint = false;
if (enabled) {
const defaultTileUrl = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
const defaultTileUrl = OPENFREEMAP_DEFAULT_STYLE;
const defaultNominatimUrl = "https://nominatim.openstreetmap.org";
const isCustomTileLocal = this.isLocalUrl(this.tileServerUrl);
const isDefaultTileOnline = this.isDefaultOnlineUrl(this.tileServerUrl);
const hasCustomTile = this.tileServerUrl && this.tileServerUrl !== defaultTileUrl;
const hasCustomTile =
this.tileServerUrl &&
this.tileServerUrl !== defaultTileUrl &&
this.tileServerUrl !== LEGACY_DEFAULT_OSM_RASTER;
const isCustomNominatimLocal = this.isLocalUrl(this.nominatimApiUrl);
const isDefaultNominatimOnline = this.isDefaultOnlineUrl(this.nominatimApiUrl);
@@ -1886,7 +1927,7 @@ export default {
this.isExportMode = false;
this.clearSearch();
}
this.updateMapSource();
await this.updateMapSource();
await this.saveMapState();
// Persist setting
@@ -2006,7 +2047,7 @@ export default {
this.offlineEnabled = true;
await this.loadMBTilesList();
await this.checkOfflineMap();
this.updateMapSource();
await this.updateMapSource();
ToastUtils.success(this.$t("map.upload_success"));
// If the map has bounds, we might want to fit to them
@@ -2055,7 +2096,7 @@ export default {
await window.api.patch("/api/v1/config", {
map_tile_server_url: this.tileServerUrl,
});
this.updateMapSource();
await this.updateMapSource();
ToastUtils.success(this.$t("map.tile_server_saved"));
await this.saveMapState();
} catch {
@@ -2065,8 +2106,10 @@ export default {
setTileServer(type) {
this.tileErrorCount = 0;
this.showOfflineHint = false;
if (type === "osm") {
this.tileServerUrl = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
if (type === "openfreemap") {
this.tileServerUrl = OPENFREEMAP_DEFAULT_STYLE;
} else if (type === "osm") {
this.tileServerUrl = LEGACY_DEFAULT_OSM_RASTER;
} else if (type === "carto-dark") {
this.tileServerUrl = "https://basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png";
} else if (type === "carto-voyager") {
@@ -2667,36 +2710,46 @@ export default {
if (evt.dragging || this.isDrawing || this.isMeasuring) return;
const pixel = this.map.getEventPixel(evt.originalEvent);
const feature = this.map.forEachFeatureAtPixel(pixel, (f) => f);
if (this._pointerMoveRaf != null) {
this._pendingPointerPixel = pixel;
return;
}
this._pendingPointerPixel = pixel;
this._pointerMoveRaf = requestAnimationFrame(() => {
this._pointerMoveRaf = null;
const p = this._pendingPointerPixel;
this._pendingPointerPixel = null;
if (!this.map || !p) return;
if (feature) {
const hasNote = feature.get("note") || (feature.get("telemetry") && feature.get("telemetry").note);
if (hasNote) {
this.hoveredFeature = feature;
const feature = this.map.forEachFeatureAtPixel(p, (f) => f);
if (feature) {
const hasNote = feature.get("note") || (feature.get("telemetry") && feature.get("telemetry").note);
if (hasNote) {
this.hoveredFeature = feature;
} else {
this.hoveredFeature = null;
}
const isMarker = feature.get("telemetry") || feature.get("discovered") || feature.get("cluster");
if (isMarker && this.hoveredMarker !== feature) {
const oldHovered = this.hoveredMarker;
this.hoveredMarker = feature;
feature.changed();
if (oldHovered) oldHovered.changed();
}
this.map.getTargetElement().style.cursor = "pointer";
} else {
this.hoveredFeature = null;
if (this.hoveredMarker) {
const oldHovered = this.hoveredMarker;
this.hoveredMarker = null;
oldHovered.changed();
}
this.map.getTargetElement().style.cursor = "";
}
// Handle marker hover effects
const isMarker = feature.get("telemetry") || feature.get("discovered") || feature.get("cluster");
if (isMarker && this.hoveredMarker !== feature) {
const oldHovered = this.hoveredMarker;
this.hoveredMarker = feature;
// Trigger style refresh
feature.changed();
if (oldHovered) oldHovered.changed();
}
this.map.getTargetElement().style.cursor = "pointer";
} else {
this.hoveredFeature = null;
if (this.hoveredMarker) {
const oldHovered = this.hoveredMarker;
this.hoveredMarker = null;
oldHovered.changed();
}
this.map.getTargetElement().style.cursor = "";
}
});
},
handleMapClick(evt) {

View File

@@ -62,10 +62,11 @@ class TileCache {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([STORE_NAME], "readwrite");
const store = transaction.objectStore(STORE_NAME);
const request = store.put(data, key);
store.put(data, key);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () => reject(transaction.error || new Error("IndexedDB transaction aborted"));
});
}
@@ -86,10 +87,11 @@ class TileCache {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([STATE_STORE], "readwrite");
const store = transaction.objectStore(STATE_STORE);
const request = store.put(data, key);
store.put(data, key);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () => reject(transaction.error || new Error("IndexedDB transaction aborted"));
});
}

View File

@@ -14,6 +14,13 @@ vi.mock("@/js/TileCache", () => ({
},
}));
vi.mock("ol-mapbox-style", () => ({
apply: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("ol/layer/Group", () => ({
default: vi.fn(function () {}),
}));
// Mock OpenLayers (Vitest 4: constructor mocks must use function/class, not arrow fns)
vi.mock("ol/Map", () => ({
default: vi.fn().mockImplementation(function () {
@@ -313,6 +320,7 @@ describe("MapPage.vue - Drawing and Measurement Tools", () => {
it("saves a drawing layer", async () => {
const wrapper = mountMapPage();
await wrapper.vm.$nextTick();
await new Promise((resolve) => setTimeout(resolve, 50));
wrapper.vm.showSaveDrawingModal = true;
wrapper.vm.newDrawingName = "Test Layer";

View File

@@ -13,6 +13,13 @@ vi.mock("@/js/TileCache", () => ({
},
}));
vi.mock("ol-mapbox-style", () => ({
apply: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("ol/layer/Group", () => ({
default: vi.fn(function () {}),
}));
// Mock OpenLayers (Vitest 4: constructor mocks must use function/class, not arrow fns)
vi.mock("ol/Map", () => ({
default: vi.fn().mockImplementation(function () {

View File

@@ -12,6 +12,13 @@ vi.mock("@/js/TileCache", () => ({
},
}));
vi.mock("ol-mapbox-style", () => ({
apply: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("ol/layer/Group", () => ({
default: vi.fn(function () {}),
}));
const viewMock = {
on: vi.fn(),
setCenter: vi.fn(),

View File

@@ -1,82 +1,217 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from "vitest";
const DB_NAME = "meshchat_map_cache";
function tag(o) {
return Object.prototype.toString.call(o);
}
function tilePayloadToUint8(got) {
if (got == null) {
return null;
}
const t = tag(got);
if (t === "[object Uint8Array]") {
return got;
}
if (t === "[object ArrayBuffer]") {
return new Uint8Array(got);
}
if (Array.isArray(got)) {
return Uint8Array.from(got);
}
return null;
}
async function tilePayloadToUint8Async(got) {
if (got == null) {
return null;
}
if (tag(got) === "[object Blob]") {
return new Uint8Array(await got.arrayBuffer());
}
return tilePayloadToUint8(got);
}
function deleteMapCacheDb() {
const idb = globalThis.indexedDB;
if (!idb) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
const req = idb.deleteDatabase(DB_NAME);
req.onsuccess = () => resolve();
req.onblocked = () => resolve();
req.onerror = () => reject(req.error);
});
}
describe("TileCache.js", () => {
let TileCache;
const DB_NAME = "meshchat_map_cache";
const DB_VERSION = 2;
describe("indexedDB detection (mocked)", () => {
const DB_VERSION = 2;
let savedIndexedDb;
let savedGlobalIndexedDb;
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
beforeEach(async () => {
savedIndexedDb = window.indexedDB;
savedGlobalIndexedDb = globalThis.indexedDB;
vi.resetModules();
vi.clearAllMocks();
// Clear all possible indexedDB properties
delete window.indexedDB;
delete window.mozIndexedDB;
delete window.webkitIndexedDB;
delete window.msIndexedDB;
delete globalThis.indexedDB;
delete window.indexedDB;
delete window.mozIndexedDB;
delete window.webkitIndexedDB;
delete window.msIndexedDB;
delete globalThis.indexedDB;
});
afterEach(() => {
if (savedIndexedDb !== undefined) {
window.indexedDB = savedIndexedDb;
}
if (savedGlobalIndexedDb !== undefined) {
globalThis.indexedDB = savedGlobalIndexedDb;
}
});
it("should support window.indexedDB", async () => {
const mockRequest = { onsuccess: null, onerror: null };
const mockOpen = vi.fn().mockReturnValue(mockRequest);
window.indexedDB = { open: mockOpen };
await import("@/js/TileCache");
expect(mockOpen).toHaveBeenCalledWith(DB_NAME, DB_VERSION);
});
it("should support vendor prefixes (mozIndexedDB)", async () => {
const mockRequest = { onsuccess: null, onerror: null };
const mockOpen = vi.fn().mockReturnValue(mockRequest);
window.mozIndexedDB = { open: mockOpen };
await import("@/js/TileCache");
expect(mockOpen).toHaveBeenCalledWith(DB_NAME, DB_VERSION);
});
it("should support vendor prefixes (webkitIndexedDB)", async () => {
const mockRequest = { onsuccess: null, onerror: null };
const mockOpen = vi.fn().mockReturnValue(mockRequest);
window.webkitIndexedDB = { open: mockOpen };
await import("@/js/TileCache");
expect(mockOpen).toHaveBeenCalledWith(DB_NAME, DB_VERSION);
});
it("should support vendor prefixes (msIndexedDB)", async () => {
const mockRequest = { onsuccess: null, onerror: null };
const mockOpen = vi.fn().mockReturnValue(mockRequest);
window.msIndexedDB = { open: mockOpen };
await import("@/js/TileCache");
expect(mockOpen).toHaveBeenCalledWith(DB_NAME, DB_VERSION);
});
it("should support globalThis.indexedDB", async () => {
const mockRequest = { onsuccess: null, onerror: null };
const mockOpen = vi.fn().mockReturnValue(mockRequest);
globalThis.indexedDB = { open: mockOpen };
await import("@/js/TileCache");
expect(mockOpen).toHaveBeenCalledWith(DB_NAME, DB_VERSION);
});
it("should reject if IndexedDB is not supported", async () => {
const module = await import("@/js/TileCache");
const cache = module.default;
await expect(cache.initPromise).rejects.toBe("IndexedDB not supported");
});
});
it("should support window.indexedDB", async () => {
const mockRequest = { onsuccess: null, onerror: null };
const mockOpen = vi.fn().mockReturnValue(mockRequest);
window.indexedDB = { open: mockOpen };
describe("tile and map_state storage (fake-indexeddb)", () => {
let TileCache;
// Re-import to trigger constructor and init
const module = await import("@/js/TileCache");
const cache = module.default;
beforeAll(async () => {
vi.resetModules();
await deleteMapCacheDb();
const mod = await import("@/js/TileCache");
TileCache = mod.default;
await TileCache.initPromise;
});
expect(mockOpen).toHaveBeenCalledWith(DB_NAME, DB_VERSION);
});
beforeEach(async () => {
await TileCache.clear();
});
it("should support vendor prefixes (mozIndexedDB)", async () => {
const mockRequest = { onsuccess: null, onerror: null };
const mockOpen = vi.fn().mockReturnValue(mockRequest);
window.mozIndexedDB = { open: mockOpen };
it("stores and retrieves binary tile data under the URL key", async () => {
const key = "https://tiles.example/z/x/y.png";
const bytes = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const module = await import("@/js/TileCache");
const cache = module.default;
// IndexedDB structured clone stores ArrayBuffer reliably in jsdom + fake-indexeddb.
// MapPage passes Blob from fetch(); both Blob and ArrayBuffer are valid values.
await TileCache.setTile(key, bytes.buffer);
const got = await TileCache.getTile(key);
expect(mockOpen).toHaveBeenCalledWith(DB_NAME, DB_VERSION);
});
const out = await tilePayloadToUint8Async(got);
expect(out).not.toBeNull();
expect(Array.from(out)).toEqual(Array.from(bytes));
});
it("should support vendor prefixes (webkitIndexedDB)", async () => {
const mockRequest = { onsuccess: null, onerror: null };
const mockOpen = vi.fn().mockReturnValue(mockRequest);
window.webkitIndexedDB = { open: mockOpen };
it("stores and retrieves a tile Blob under the URL key when the runtime preserves Blob", async () => {
const key = "https://tiles.example/blob.png";
const bytes = Uint8Array.from([0x89, 0x50, 0x4e, 0x47]);
const blob = new Blob([bytes], { type: "image/png" });
const module = await import("@/js/TileCache");
const cache = module.default;
await TileCache.setTile(key, blob);
const got = await TileCache.getTile(key);
expect(mockOpen).toHaveBeenCalledWith(DB_NAME, DB_VERSION);
});
if (!(got instanceof Blob)) {
return;
}
it("should support vendor prefixes (msIndexedDB)", async () => {
const mockRequest = { onsuccess: null, onerror: null };
const mockOpen = vi.fn().mockReturnValue(mockRequest);
window.msIndexedDB = { open: mockOpen };
expect(got.type).toBe("image/png");
expect(got.size).toBe(bytes.length);
const out = new Uint8Array(await got.arrayBuffer());
expect(Array.from(out)).toEqual(Array.from(bytes));
});
const module = await import("@/js/TileCache");
const cache = module.default;
it("returns undefined for a missing tile key", async () => {
const got = await TileCache.getTile("https://missing.example/0/0/0.png");
expect(got).toBeUndefined();
});
expect(mockOpen).toHaveBeenCalledWith(DB_NAME, DB_VERSION);
});
it("overwrites an existing tile key", async () => {
const key = "https://tiles.example/same.png";
await TileCache.setTile(key, Uint8Array.from([1, 2, 3]).buffer);
await TileCache.setTile(key, Uint8Array.from([9, 9]).buffer);
it("should support globalThis.indexedDB", async () => {
const mockRequest = { onsuccess: null, onerror: null };
const mockOpen = vi.fn().mockReturnValue(mockRequest);
globalThis.indexedDB = { open: mockOpen };
const got = await TileCache.getTile(key);
const out = await tilePayloadToUint8Async(got);
expect(out).not.toBeNull();
expect(out.length).toBe(2);
expect(out[0]).toBe(9);
});
const module = await import("@/js/TileCache");
const cache = module.default;
it("stores and retrieves map state independently of tiles", async () => {
const state = { center: [12.5, 55.1], zoom: 7, offlineEnabled: false };
await TileCache.setMapState("last_view", state);
const got = await TileCache.getMapState("last_view");
expect(mockOpen).toHaveBeenCalledWith(DB_NAME, DB_VERSION);
});
expect(got).toEqual(state);
});
it("should reject if IndexedDB is not supported", async () => {
const module = await import("@/js/TileCache");
const cache = module.default;
it("clear removes tiles and map state", async () => {
await TileCache.setTile("https://x/t.png", Uint8Array.from([97]).buffer);
await TileCache.setMapState("last_view", { zoom: 1 });
await expect(cache.initPromise).rejects.toBe("IndexedDB not supported");
await TileCache.clear();
expect(await TileCache.getTile("https://x/t.png")).toBeUndefined();
expect(await TileCache.getMapState("last_view")).toBeUndefined();
});
});
});