mirror of
https://github.com/maputnik/editor.git
synced 2026-07-15 10:27:25 +00:00
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:
@@ -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" }} />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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" }} />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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" })
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user