Increase coverage (#1997)

## Launch Checklist

This PR increases coverage by adding unit tests to lib folde, replace
the skipped end to end placeholder with actual tests and adds more end
to end tests.

This was mostly done by AI (Claude opus 4.8) and I reviewed it and
requested changes where needed.

 - [x] Briefly describe the changes in this PR.
 - [x] Link to related issues.
- [x] Include before/after visuals or gifs if this PR includes visual
changes.
 - [x] Write tests for all new functionality.
 - [x] Add an entry to `CHANGELOG.md` under the `## main` section.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Harel M
2026-07-12 15:46:30 +03:00
committed by GitHub
parent 9c1499b805
commit f14eeae38b
28 changed files with 1443 additions and 303 deletions
+5
View File
@@ -289,6 +289,7 @@ class DataPropertyInternal extends React.Component<DataPropertyInternalProps, Da
<Block
label={t("Function")}
key="function"
data-wd-key="function-type"
>
<div className="maputnik-data-spec-property-input">
<InputSelect
@@ -303,6 +304,7 @@ class DataPropertyInternal extends React.Component<DataPropertyInternalProps, Da
<Block
label={t("Base")}
key="base"
data-wd-key="function-base"
>
<div className="maputnik-data-spec-property-input">
<InputSpec
@@ -317,6 +319,7 @@ class DataPropertyInternal extends React.Component<DataPropertyInternalProps, Da
<Block
label={"Property"}
key="property"
data-wd-key="function-property"
>
<div className="maputnik-data-spec-property-input">
<InputString
@@ -330,6 +333,7 @@ class DataPropertyInternal extends React.Component<DataPropertyInternalProps, Da
<Block
label={t("Default")}
key="default"
data-wd-key="function-default"
>
<InputSpec
fieldName={this.props.fieldName}
@@ -368,6 +372,7 @@ class DataPropertyInternal extends React.Component<DataPropertyInternalProps, Da
}
<InputButton
className="maputnik-add-stop"
data-wd-key="convert-to-expression"
onClick={this.props.onExpressionClick?.bind(this)}
>
<TbMathFunction style={{ verticalAlign: "text-bottom" }} />
+2
View File
@@ -50,6 +50,7 @@ class ExpressionPropertyInternal extends React.Component<ExpressionPropertyInter
onClick={this.props.onUndo}
disabled={undoDisabled}
className="maputnik-delete-stop"
data-wd-key="undo-expression"
title={t("Revert from expression")}
>
<MdUndo />
@@ -59,6 +60,7 @@ class ExpressionPropertyInternal extends React.Component<ExpressionPropertyInter
key="delete_action"
onClick={this.props.onDelete}
className="maputnik-delete-stop"
data-wd-key="delete-expression"
title={t("Delete expression")}
>
<MdDelete />
+2
View File
@@ -215,6 +215,7 @@ class FilterEditorInternal extends React.Component<FilterEditorInternalProps, Fi
onClick={this.makeExpression}
title={t("Convert to expression")}
className="maputnik-make-zoom-function"
data-wd-key="filter-convert-to-expression"
>
<TbMathFunction />
</InputButton>
@@ -248,6 +249,7 @@ class FilterEditorInternal extends React.Component<FilterEditorInternalProps, Fi
fieldSpec={fieldSpec}
label={t("Filter")}
action={actions}
data-wd-key="filter-combining-operator"
>
<InputSelect
value={combiningOp}
@@ -1,24 +0,0 @@
import { expect, test } from "vitest";
import { render } from "vitest-browser-react";
import { page } from "vitest/browser";
import { InputAutocomplete } from "./InputAutocomplete";
const fruits = ["apple", "banana", "cherry"];
test("filters options when typing", async () => {
render(<InputAutocomplete aria-label="Fruit" options={fruits.map((f) => [f, f])} />);
const input = page.getByLabelText("Fruit");
await input.click();
const menuItems = page.getByRole("option");
await expect.element(menuItems.first()).toBeVisible();
expect(menuItems.all()).toHaveLength(3);
await input.fill("ch");
await expect.element(page.getByText("cherry")).toBeVisible();
expect(page.getByRole("option").all()).toHaveLength(1);
await page.getByText("cherry").click();
await expect.element(input).toHaveValue("cherry");
});
+1
View File
@@ -230,6 +230,7 @@ class LayerEditorInternal extends React.Component<LayerEditorInternalProps, Laye
)}
/>
{this.props.layer.type !== "background" && <FieldSource
wdKey="layer-editor.layer-source"
error={errorData.source}
sourceIds={Object.keys(this.props.sources!)}
value={this.props.layer.source}
+3
View File
@@ -194,6 +194,7 @@ class ZoomPropertyInternal extends React.Component<ZoomPropertyInternalProps, Zo
<div className="maputnik-data-fieldset-inner">
<Block
label={t("Function")}
data-wd-key="function-type"
>
<div className="maputnik-data-spec-property-input">
<InputSelect
@@ -206,6 +207,7 @@ class ZoomPropertyInternal extends React.Component<ZoomPropertyInternalProps, Zo
</Block>
<Block
label={t("Base")}
data-wd-key="function-base"
>
<div className="maputnik-data-spec-property-input">
<InputSpec
@@ -240,6 +242,7 @@ class ZoomPropertyInternal extends React.Component<ZoomPropertyInternalProps, Zo
</InputButton>
<InputButton
className="maputnik-add-stop"
data-wd-key="convert-to-expression"
onClick={this.props.onExpressionClick?.bind(this)}
>
<TbMathFunction style={{ verticalAlign: "text-bottom" }} />
+7
View File
@@ -206,6 +206,7 @@ class ModalSettingsInternal extends React.Component<ModalSettingsInternalProps>
<FieldNumber
label={t("Zoom")}
data-wd-key="modal:settings.zoom"
fieldSpec={latest.$root.zoom}
value={mapStyle.zoom}
default={0}
@@ -214,6 +215,7 @@ class ModalSettingsInternal extends React.Component<ModalSettingsInternalProps>
<FieldNumber
label={t("Bearing")}
data-wd-key="modal:settings.bearing"
fieldSpec={latest.$root.bearing}
value={mapStyle.bearing}
default={latest.$root.bearing.default}
@@ -222,6 +224,7 @@ class ModalSettingsInternal extends React.Component<ModalSettingsInternalProps>
<FieldNumber
label={t("Pitch")}
data-wd-key="modal:settings.pitch"
fieldSpec={latest.$root.pitch}
value={mapStyle.pitch}
default={latest.$root.pitch.default}
@@ -248,6 +251,7 @@ class ModalSettingsInternal extends React.Component<ModalSettingsInternalProps>
<FieldNumber
label={t("Light intensity")}
data-wd-key="modal:settings.light-intensity"
fieldSpec={latest.light.intensity}
value={light.intensity as number}
default={latest.light.intensity.default}
@@ -274,6 +278,7 @@ class ModalSettingsInternal extends React.Component<ModalSettingsInternalProps>
<FieldNumber
label={t("Terrain exaggeration")}
data-wd-key="modal:settings.terrain-exaggeration"
fieldSpec={latest.terrain.exaggeration}
value={terrain.exaggeration}
default={latest.terrain.exaggeration.default}
@@ -282,6 +287,7 @@ class ModalSettingsInternal extends React.Component<ModalSettingsInternalProps>
<FieldNumber
label={t("Transition delay")}
data-wd-key="modal:settings.transition-delay"
fieldSpec={latest.transition.delay}
value={transition.delay}
default={latest.transition.delay.default}
@@ -290,6 +296,7 @@ class ModalSettingsInternal extends React.Component<ModalSettingsInternalProps>
<FieldNumber
label={t("Transition duration")}
data-wd-key="modal:settings.transition-duration"
fieldSpec={latest.transition.duration}
value={transition.duration}
default={latest.transition.duration.default}
+48
View File
@@ -0,0 +1,48 @@
import { describe, it, expect } from "vitest";
import type { LayerSpecification } from "maplibre-gl";
import { colorHighlightedLayer } from "./highlight";
function layer(overrides: Record<string, any>): 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);
});
});
+94
View File
@@ -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);
});
});
+46
View File
@@ -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");
});
});
+12
View File
@@ -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);
});
});
+17
View File
@@ -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([]);
});
});
+76
View File
@@ -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" })
);
});
});
+64
View File
@@ -0,0 +1,64 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { StyleStore } from "./stylestore";
class LocalStorageMock {
private store: Record<string, string> = {};
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");
});
});
-10
View File
@@ -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,