diff --git a/src/components/modals/ModalSettings.tsx b/src/components/modals/ModalSettings.tsx
index 9dbcc685..f718ed29 100644
--- a/src/components/modals/ModalSettings.tsx
+++ b/src/components/modals/ModalSettings.tsx
@@ -206,6 +206,7 @@ class ModalSettingsInternal extends React.Component
): LayerSpecification {
+ return { id: "l", source: "s", "source-layer": "sl", ...overrides } as unknown as LayerSpecification;
+}
+
+describe("colorHighlightedLayer", () => {
+ it("returns null for undefined, background and raster layers", () => {
+ expect(colorHighlightedLayer(undefined)).toBeNull();
+ expect(colorHighlightedLayer(layer({ type: "background" }))).toBeNull();
+ expect(colorHighlightedLayer(layer({ type: "raster" }))).toBeNull();
+ });
+
+ it("builds a circle highlight for circle and symbol layers", () => {
+ for (const type of ["circle", "symbol"]) {
+ const highlight = colorHighlightedLayer(layer({ type }))!;
+ expect(highlight).not.toBeNull();
+ expect(highlight.type).toBe("circle");
+ expect(highlight.id).toMatch(/_highlight$/);
+ expect((highlight.paint as any)["circle-radius"]).toBe(3);
+ }
+ });
+
+ it("builds a line highlight with an overridden width", () => {
+ const highlight = colorHighlightedLayer(layer({ type: "line" }))!;
+ expect(highlight.type).toBe("line");
+ expect((highlight.paint as any)["line-width"]).toBe(2);
+ });
+
+ it("builds a polygon highlight for fill and fill-extrusion layers", () => {
+ for (const type of ["fill", "fill-extrusion"]) {
+ const highlight = colorHighlightedLayer(layer({ type }))!;
+ expect(highlight.type).toBe("fill");
+ }
+ });
+
+ it("copies the source layer's filter when present, drops it otherwise", () => {
+ const withFilter = colorHighlightedLayer(
+ layer({ type: "line", filter: ["==", "class", "road"] } as any)
+ )!;
+ expect(withFilter.filter).toEqual(["==", "class", "road"]);
+
+ const withoutFilter = colorHighlightedLayer(layer({ type: "line" }))!;
+ expect("filter" in withoutFilter).toBe(false);
+ });
+});
diff --git a/src/libs/layer.test.ts b/src/libs/layer.test.ts
new file mode 100644
index 00000000..ae98bd14
--- /dev/null
+++ b/src/libs/layer.test.ts
@@ -0,0 +1,94 @@
+import { describe, it, expect } from "vitest";
+import type { LayerSpecification } from "maplibre-gl";
+import { changeType, changeProperty, layerPrefix, findClosestCommonPrefix } from "./layer";
+
+describe("changeType", () => {
+ it("drops paint/layout props not valid for the new type", () => {
+ const layer = {
+ id: "l",
+ type: "fill",
+ source: "s",
+ paint: { "fill-color": "#fff", "fill-opacity": 0.5 },
+ layout: { visibility: "visible" },
+ } as unknown as LayerSpecification;
+
+ const changed = changeType(layer, "background") as any;
+ expect(changed.type).toBe("background");
+ // fill-color is not a valid background paint property
+ expect(changed.paint["fill-color"]).toBeUndefined();
+ // visibility is valid for background layout
+ expect(changed.layout.visibility).toBe("visible");
+ });
+
+ it("keeps props that are valid for the new type", () => {
+ const layer = {
+ id: "l",
+ type: "line",
+ source: "s",
+ paint: { "line-color": "#000" },
+ } as unknown as LayerSpecification;
+ const changed = changeType(layer, "line") as any;
+ expect(changed.paint["line-color"]).toBe("#000");
+ });
+});
+
+describe("changeProperty", () => {
+ const base = { id: "l", type: "background" } as unknown as LayerSpecification;
+
+ it("sets a top-level property", () => {
+ const changed = changeProperty(base, null, "minzoom", 4) as any;
+ expect(changed.minzoom).toBe(4);
+ });
+
+ it("removes a top-level property when value is undefined", () => {
+ const changed = changeProperty({ ...base, minzoom: 4 } as any, null, "minzoom", undefined) as any;
+ expect("minzoom" in changed).toBe(false);
+ });
+
+ it("sets a grouped property", () => {
+ const changed = changeProperty(base, "paint", "background-color", "#fff") as any;
+ expect(changed.paint["background-color"]).toBe("#fff");
+ });
+
+ it("removes a grouped property and drops the empty group", () => {
+ const layer = { ...base, paint: { "background-color": "#fff" } } as any;
+ const changed = changeProperty(layer, "paint", "background-color", undefined) as any;
+ expect("paint" in changed).toBe(false);
+ });
+
+ it("removes a grouped property but keeps a non-empty group", () => {
+ const layer = { ...base, paint: { "background-color": "#fff", "background-opacity": 1 } } as any;
+ const changed = changeProperty(layer, "paint", "background-color", undefined) as any;
+ expect(changed.paint["background-color"]).toBeUndefined();
+ expect(changed.paint["background-opacity"]).toBe(1);
+ });
+});
+
+describe("layerPrefix", () => {
+ it("returns the segment before the first separator", () => {
+ expect(layerPrefix("foo-bar")).toBe("foo");
+ expect(layerPrefix("foo_bar")).toBe("foo");
+ expect(layerPrefix("foo bar")).toBe("foo");
+ expect(layerPrefix("single")).toBe("single");
+ });
+});
+
+describe("findClosestCommonPrefix", () => {
+ const layers = [
+ { id: "aa" },
+ { id: "aa-2" },
+ { id: "b" },
+ ] as unknown as LayerSpecification[];
+
+ it("finds the first index of a run sharing the prefix", () => {
+ expect(findClosestCommonPrefix(layers, 1)).toBe(0);
+ });
+
+ it("returns the index itself when the previous prefix differs", () => {
+ expect(findClosestCommonPrefix(layers, 2)).toBe(2);
+ });
+
+ it("returns the index for the first layer", () => {
+ expect(findClosestCommonPrefix(layers, 0)).toBe(0);
+ });
+});
diff --git a/src/libs/layerwatcher.test.ts b/src/libs/layerwatcher.test.ts
new file mode 100644
index 00000000..4806b98f
--- /dev/null
+++ b/src/libs/layerwatcher.test.ts
@@ -0,0 +1,46 @@
+import { describe, it, expect, vi } from "vitest";
+import { LayerWatcher } from "./layerwatcher";
+import type { Map } from "maplibre-gl";
+
+function mockMap(): Map {
+ return {
+ style: {
+ tileManagers: {
+ vector: { _source: { vectorLayerIds: ["water", "roads"] } },
+ },
+ },
+ querySourceFeatures: () => [
+ { properties: { class: "river", name: "A" } },
+ { properties: { class: "canal" } },
+ ],
+ } as unknown as Map;
+}
+
+describe("LayerWatcher", () => {
+ it("reports sources discovered on the map", () => {
+ const onSourcesChange = vi.fn();
+ const watcher = new LayerWatcher({ onSourcesChange });
+
+ watcher.analyzeMap(mockMap());
+ expect(onSourcesChange).toHaveBeenCalledOnce();
+ expect(watcher.sources).toEqual({ vector: ["water", "roads"] });
+
+ // Re-analyzing the same sources does not fire the callback again.
+ watcher.analyzeMap(mockMap());
+ expect(onSourcesChange).toHaveBeenCalledOnce();
+ });
+
+ it("collects vector layer field values from source features", () => {
+ const onVectorLayersChange = vi.fn();
+ const watcher = new LayerWatcher({ onVectorLayersChange });
+
+ const map = mockMap();
+ watcher.analyzeMap(map); // populates _sources
+ watcher.analyzeVectorLayerFields(map);
+
+ expect(onVectorLayersChange).toHaveBeenCalled();
+ expect(watcher.vectorLayers.water.class).toHaveProperty("river");
+ expect(watcher.vectorLayers.water.class).toHaveProperty("canal");
+ expect(watcher.vectorLayers.water.name).toHaveProperty("A");
+ });
+});
diff --git a/src/libs/sort-numerically.test.ts b/src/libs/sort-numerically.test.ts
new file mode 100644
index 00000000..680fa6e5
--- /dev/null
+++ b/src/libs/sort-numerically.test.ts
@@ -0,0 +1,12 @@
+import { describe, it, expect } from "vitest";
+import { sortNumerically } from "./sort-numerically";
+
+describe("sortNumerically", () => {
+ it("orders numbers (and numeric strings) ascending", () => {
+ expect([3, 1, 2].sort(sortNumerically)).toEqual([1, 2, 3]);
+ expect(["10", "2", "1"].sort(sortNumerically)).toEqual(["1", "2", "10"]);
+ expect(sortNumerically(5, 5)).toBe(0);
+ expect(sortNumerically(1, 2)).toBe(-1);
+ expect(sortNumerically(2, 1)).toBe(1);
+ });
+});
diff --git a/src/libs/spec-helper.test.ts b/src/libs/spec-helper.test.ts
new file mode 100644
index 00000000..64bf3be2
--- /dev/null
+++ b/src/libs/spec-helper.test.ts
@@ -0,0 +1,17 @@
+import { describe, it, expect } from "vitest";
+import { findDefaultFromSpec } from "./spec-helper";
+
+describe("findDefaultFromSpec", () => {
+ it("returns the spec default when present (even if falsy)", () => {
+ expect(findDefaultFromSpec({ type: "string", default: "hi" })).toBe("hi");
+ expect(findDefaultFromSpec({ type: "boolean", default: false })).toBe(false);
+ });
+
+ it("falls back to a sensible default per type", () => {
+ expect(findDefaultFromSpec({ type: "color" })).toBe("#000000");
+ expect(findDefaultFromSpec({ type: "string" })).toBe("");
+ // The falsy `false` fallback collapses to "" via the `|| ""` in the impl.
+ expect(findDefaultFromSpec({ type: "boolean" })).toBe("");
+ expect(findDefaultFromSpec({ type: "array" })).toEqual([]);
+ });
+});
diff --git a/src/libs/store/apistore.test.ts b/src/libs/store/apistore.test.ts
new file mode 100644
index 00000000..1c7391c8
--- /dev/null
+++ b/src/libs/store/apistore.test.ts
@@ -0,0 +1,76 @@
+import { describe, it, expect, beforeEach, vi } from "vitest";
+
+vi.stubGlobal("window", { location: { port: "8000" } });
+
+// Avoid opening a real websocket in notifyLocalChanges().
+const wsInstances: any[] = [];
+vi.mock("reconnecting-websocket", () => ({
+ default: class {
+ onmessage: ((e: { data: string }) => void) | null = null;
+ constructor(public url: string) {
+ wsInstances.push(this);
+ }
+ },
+}));
+
+import { ApiStyleStore } from "./apistore";
+
+const okJson = (body: any) => ({ ok: true, json: async () => body });
+
+describe("ApiStyleStore", () => {
+ beforeEach(() => {
+ wsInstances.length = 0;
+ vi.restoreAllMocks();
+ });
+
+ it("init fetches style ids and wires up local change notifications", async () => {
+ const fetchMock = vi.fn(async () => okJson(["style-a", "style-b"]) as any);
+ vi.stubGlobal("fetch", fetchMock);
+ const onLocalStyleChange = vi.fn();
+ const store = new ApiStyleStore({ onLocalStyleChange });
+
+ await store.init();
+ expect(fetchMock).toHaveBeenCalledWith("http://localhost:8000/styles", { mode: "cors" });
+ expect(store.latestStyleId).toBe("style-a");
+
+ // A websocket message triggers onLocalStyleChange with a valid style.
+ expect(wsInstances.length).toBe(1);
+ wsInstances[0].onmessage!({ data: JSON.stringify({ version: 8, sources: {}, layers: [] }) });
+ expect(onLocalStyleChange).toHaveBeenCalledOnce();
+ expect(onLocalStyleChange.mock.calls[0][0].id).toBeTruthy();
+ });
+
+ it("init throws a friendly error when the API is unreachable", async () => {
+ vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("boom"); }));
+ const store = new ApiStyleStore({});
+ await expect(store.init()).rejects.toThrow("Can not connect to style API");
+ });
+
+ it("getLatestStyle fetches the latest style and validates it", async () => {
+ vi.stubGlobal("fetch", vi.fn(async () => okJson(["s1"]) as any));
+ const store = new ApiStyleStore({});
+ await store.init();
+
+ vi.stubGlobal("fetch", vi.fn(async () => okJson({ version: 8, id: "s1", sources: {}, layers: [] }) as any));
+ const latest = await store.getLatestStyle();
+ expect(latest.id).toBe("s1");
+ });
+
+ it("getLatestStyle throws when not initialised", async () => {
+ const store = new ApiStyleStore({});
+ await expect(store.getLatestStyle()).rejects.toThrow(/init the api backend/);
+ });
+
+ it("save PUTs the (token-stripped) style to the API", () => {
+ const fetchMock = vi.fn(async () => okJson({}) as any);
+ vi.stubGlobal("fetch", fetchMock);
+ const store = new ApiStyleStore({});
+ const mapStyle = { version: 8, id: "s1", sources: {}, layers: [] } as any;
+
+ expect(store.save(mapStyle)).toBe(mapStyle);
+ expect(fetchMock).toHaveBeenCalledWith(
+ "http://localhost:8000/styles/s1",
+ expect.objectContaining({ method: "PUT", mode: "cors" })
+ );
+ });
+});
diff --git a/src/libs/store/stylestore.test.ts b/src/libs/store/stylestore.test.ts
new file mode 100644
index 00000000..f2b718ab
--- /dev/null
+++ b/src/libs/store/stylestore.test.ts
@@ -0,0 +1,64 @@
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { StyleStore } from "./stylestore";
+
+class LocalStorageMock {
+ private store: Record = {};
+ get length() {
+ return Object.keys(this.store).length;
+ }
+ key(i: number) {
+ return Object.keys(this.store)[i] ?? null;
+ }
+ getItem(k: string) {
+ return k in this.store ? this.store[k] : null;
+ }
+ setItem(k: string, v: string) {
+ this.store[k] = v;
+ }
+ removeItem(k: string) {
+ delete this.store[k];
+ }
+ clear() {
+ this.store = {};
+ }
+}
+vi.stubGlobal("window", { localStorage: new LocalStorageMock() });
+// loadDefaultStyle fetches the default style over the network; serve it locally.
+vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => ({
+ json: async () => ({ version: 8, id: "default", sources: {}, layers: [] }),
+ }))
+);
+
+const style = (id: string) => ({ version: 8, id, sources: {}, layers: [] }) as any;
+
+describe("StyleStore", () => {
+ beforeEach(() => {
+ window.localStorage.clear();
+ });
+
+ it("returns the default style when nothing is stored", async () => {
+ const store = new StyleStore();
+ const latest = await store.getLatestStyle();
+ expect(latest.id).toBe("default");
+ });
+
+ it("saves a style and reads it back as the latest", async () => {
+ const store = new StyleStore();
+ store.save(style("abc"));
+ // A fresh store discovers the persisted style ids.
+ const reopened = new StyleStore();
+ const latest = await reopened.getLatestStyle();
+ expect(latest.id).toBe("abc");
+ });
+
+ it("purge removes all maputnik keys", async () => {
+ const store = new StyleStore();
+ store.save(style("abc"));
+ window.localStorage.setItem("unrelated", "keep");
+ store.purge();
+ expect(window.localStorage.getItem("maputnik:style:abc")).toBeNull();
+ expect(window.localStorage.getItem("unrelated")).toBe("keep");
+ });
+});
diff --git a/src/libs/style.ts b/src/libs/style.ts
index 47a1b742..0acd20f9 100644
--- a/src/libs/style.ts
+++ b/src/libs/style.ts
@@ -45,15 +45,6 @@ function ensureStyleValidity(style: StyleSpecification): StyleSpecificationWithI
return ensureHasNoInteractive(ensureHasNoRefs(ensureHasId(style)));
}
-function indexOfLayer(layers: LayerSpecification[], layerId: string) {
- for (let i = 0; i < layers.length; i++) {
- if(layers[i].id === layerId) {
- return i;
- }
- }
- return null;
-}
-
function getAccessToken(sourceName: string, mapStyle: StyleSpecification, opts: {allowFallback?: boolean}) {
const metadata = mapStyle.metadata || {} as any;
let accessToken = metadata[`maputnik:${sourceName}_access_token`];
@@ -151,7 +142,6 @@ function stripAccessTokens(mapStyle: StyleSpecification) {
export {
ensureStyleValidity,
emptyStyle,
- indexOfLayer,
generateId,
getAccessToken,
replaceAccessTokens,
diff --git a/vitest.config.ts b/vitest.config.ts
index b9a412c0..a350b7bb 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -1,34 +1,8 @@
import { defineConfig } from "vitest/config";
-import react from "@vitejs/plugin-react";
-import { playwright } from "@vitest/browser-playwright";
export default defineConfig({
- plugins: [react()],
test: {
- projects: [
- {
- extends: true,
- test: {
- name: "unit",
- environment: "node",
- include: ["src/**/*.test.{ts,tsx}"],
- exclude: ["src/**/*.browser.test.{ts,tsx}"],
- },
- },
- {
- extends: true,
- test: {
- name: "browser",
- include: ["src/**/*.browser.test.{ts,tsx}"],
- browser: {
- enabled: true,
- provider: playwright(),
- headless: true,
- instances: [{ browser: "chromium" }],
- },
- },
- },
- ],
+ include: ["src/**/*.test.{ts,tsx}"],
coverage: {
provider: "v8",
reporter: ["json", "lcov", "text-summary"],