Increase coverage to about 80%

This commit is contained in:
HarelM
2026-07-10 21:11:41 +03:00
parent 70a2db580f
commit d039dc73a5
21 changed files with 765 additions and 221 deletions
-1
View File
@@ -96,7 +96,6 @@ jobs:
with:
node-version-file: '.nvmrc'
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm run test-unit-ci
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
+84 -2
View File
@@ -76,6 +76,16 @@ describe("layer editor", () => {
layers: [{ id: "background:" + bgId, type: "background", minzoom: 1 }],
});
});
test("the range slider adjusts min-zoom", async () => {
await when.focus("min-zoom.input-range");
await when.typeKeys("{rightarrow}");
await then(
get
.styleFromLocalStorage()
.then((style) => typeof style.layers.find((l: any) => l.id === "background:" + bgId).minzoom)
).shouldEqual("number");
});
});
describe("max-zoom", () => {
@@ -145,6 +155,15 @@ describe("layer editor", () => {
layers: [{ id: "background:" + bgId, type: "background" }],
});
});
test("typing a hex value updates the paint color", async () => {
await when.setColorValue("background-color", "#ff0000");
await then(
get
.styleFromLocalStorage()
.then((style) => style.layers.find((l: any) => l.id === "background:" + bgId).paint["background-color"])
).shouldEqual("#ff0000");
});
});
describe("opacity", () => {
@@ -166,8 +185,71 @@ describe("layer editor", () => {
});
describe("filter", () => {
test.skip("expand/collapse", () => {});
test.skip("compound filter", () => {});
test("compound filter", async () => {
const id = await when.modal.fillLayers({ type: "fill", layer: "example" });
await when.addFilter();
await then(
get.styleFromLocalStorage().then((style) => style.layers.find((l: any) => l.id === id).filter)
).shouldDeepNestedInclude(["all", ["==", "name", ""]]);
// Changing the operator updates the compound filter.
await when.selectFilterOperator("!=");
await then(
get.styleFromLocalStorage().then((style) => style.layers.find((l: any) => l.id === id).filter)
).shouldDeepNestedInclude(["all", ["!=", "name", ""]]);
// A second filter item extends the compound filter.
await when.addFilter();
await then(
get.styleFromLocalStorage().then((style) => style.layers.find((l: any) => l.id === id).filter)
).shouldDeepNestedInclude(["all", ["!=", "name", ""], ["==", "name", ""]]);
});
});
describe("functions", () => {
test("convert a property to a zoom function and add a stop", async () => {
const id = await when.modal.fillLayers({ type: "circle", layer: "example" });
await when.makeZoomFunction("circle-radius");
await then(
get.styleFromLocalStorage().then((style) => style.layers.find((l: any) => l.id === id).paint)
).shouldDeepNestedInclude({ "circle-radius": { stops: [[6, 5], [10, 5]] } });
await when.addFunctionStop("circle-radius");
await then(
get.styleFromLocalStorage().then((style) => style.layers.find((l: any) => l.id === id).paint)
).shouldDeepNestedInclude({ "circle-radius": { stops: [[6, 5], [10, 5], [11, 5]] } });
await when.deleteFunctionStop("circle-radius");
await then(
get
.styleFromLocalStorage()
.then((style) => style.layers.find((l: any) => l.id === id).paint["circle-radius"].stops.length)
).shouldEqual(2);
});
test("convert a property to a data function and edit stops", async () => {
const id = await when.modal.fillLayers({ type: "circle", layer: "example" });
// The property needs a value before it can be turned into a data function.
await when.setValue("spec-field-input:circle-blur", "1");
await when.makeDataFunction("circle-blur");
await then(
get.styleFromLocalStorage().then((style) => style.layers.find((l: any) => l.id === id).paint["circle-blur"].type)
).shouldEqual("exponential");
await when.addFunctionStop("circle-blur");
await then(
get
.styleFromLocalStorage()
.then((style) => style.layers.find((l: any) => l.id === id).paint["circle-blur"].stops.length)
).shouldEqual(3);
await when.deleteFunctionStop("circle-blur");
await then(
get
.styleFromLocalStorage()
.then((style) => style.layers.find((l: any) => l.id === id).paint["circle-blur"].stops.length)
).shouldEqual(2);
});
});
describe("layout", () => {
+50
View File
@@ -158,6 +158,56 @@ export class MaputnikDriver {
await this.helper.when.typeText(value);
},
makeZoomFunction: async (fieldName: string) => {
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
await container.scrollIntoViewIfNeeded();
await container.locator(".maputnik-make-zoom-function").last().click({ force: true });
},
makeDataFunction: async (fieldName: string) => {
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
await container.scrollIntoViewIfNeeded();
await container.locator(".maputnik-make-data-function").click({ force: true });
},
addFunctionStop: async (fieldName: string) => {
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
await container.locator(".maputnik-add-stop").first().click({ force: true });
},
deleteFunctionStop: async (fieldName: string) => {
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
await container.locator(".maputnik-delete-stop").first().click({ force: true });
},
addFilter: async () => {
const button = this.helper.get.elementByTestId("layer-filter-button");
await button.scrollIntoViewIfNeeded();
await button.click({ force: true });
},
selectFilterOperator: async (value: string) => {
await this.helper.get.element(".maputnik-filter-editor-operator select").first().selectOption(value);
},
deleteFirstActiveSource: async () => {
await this.helper.get.element(".maputnik-active-source-type-editor-header-delete").first().click();
},
setColorValue: async (fieldName: string, value: string) => {
const input = this.helper.get.elementByTestId("spec-field:" + fieldName).locator(".maputnik-color");
await input.fill(value);
},
exportCreateHtml: async () => {
await this.helper.get.element(".maputnik-modal-export-buttons button").last().click();
},
exportSaveStyle: async () => {
await this.helper.stubSaveFilePicker();
await this.helper.get.element(".maputnik-modal-export-buttons button").first().click();
},
waitForExampleFileResponse: () => this.helper.when.waitForResponse("example-style.json"),
/** Fill localStorage until we get a QuotaExceededError. */
+20 -3
View File
@@ -64,8 +64,15 @@ describe("modals", () => {
await then(get.elementByTestId("modal:export")).shouldNotExist();
});
// TODO: Work out how to download a file and check the contents
test.skip("download", () => {});
test("download HTML and save the style", async () => {
// Generate the standalone HTML export (triggers a file download).
await when.exportCreateHtml();
await then(get.elementByTestId("modal:export")).shouldExist();
// Saving the style closes the export modal.
await when.exportSaveStyle();
await then(get.elementByTestId("modal:export")).shouldNotExist();
});
});
describe("sources", () => {
@@ -74,7 +81,17 @@ describe("modals", () => {
await when.click("nav:sources");
});
test.skip("active sources", () => {});
test("active sources are listed and can be deleted", async () => {
// The "both" style ships with active sources; reopen the modal against it.
await when.setStyle("both");
await when.click("nav:sources");
const before = Object.keys(get.fixture("geojson-raster-style.json").sources).length;
await when.deleteFirstActiveSource();
await then(
get.styleFromLocalStorage().then((style) => Object.keys(style.sources).length)
).shouldEqual(before - 1);
});
test.skip("public source", () => {});
test("add new source", async () => {
+16 -3
View File
@@ -63,9 +63,9 @@ function isLocator(target: unknown): target is Locator {
}
/** Asserts that every top-level key in `expected` deep-equals its counterpart in `actual`. */
function assertDeepNestedInclude(actual: any, expected: Record<string, unknown>): void {
function assertDeepNestedInclude(actual: any, expected: Record<string, unknown> | unknown[]): void {
for (const key of Object.keys(expected)) {
expect(actual?.[key], `property "${key}"`).toEqual(expected[key]);
expect(actual?.[key], `property "${key}"`).toEqual((expected as any)[key]);
}
}
@@ -130,7 +130,7 @@ export class Assertable<T> {
}
});
shouldDeepNestedInclude = (value: Record<string, unknown>) =>
shouldDeepNestedInclude = (value: Record<string, unknown> | unknown[]) =>
this.assertValue((actual) => assertDeepNestedInclude(actual, value));
}
@@ -144,6 +144,10 @@ async function typeSequence(page: Page, text: string): Promise<void> {
del: "Delete",
tab: "Tab",
home: "Home",
rightarrow: "ArrowRight",
leftarrow: "ArrowLeft",
uparrow: "ArrowUp",
downarrow: "ArrowDown",
};
for (let i = 0; i < tokens.length; i++) {
@@ -202,6 +206,15 @@ export class PlaywrightHelper {
return new Query<T>(getter);
}
/** Stubs the File System Access "save" picker so file saves complete headlessly. */
public stubSaveFilePicker(): Promise<void> {
return this.page.evaluate(() => {
(window as any).showSaveFilePicker = async () => ({
createWritable: async () => ({ write: async () => {}, close: async () => {} }),
});
});
}
/** Entry point for fluent assertions over a Locator or a value/Query. */
public then = <T>(target: T): Assertable<T> => new Assertable(target);
+1 -157
View File
@@ -90,8 +90,6 @@
"@types/string-hash": "^1.1.3",
"@types/wicg-file-system-access": "^2023.10.7",
"@vitejs/plugin-react": "5.2",
"@vitest/browser": "^4.1.10",
"@vitest/browser-playwright": "^4.1.10",
"@vitest/coverage-v8": "^4.1.10",
"cors": "^2.8.6",
"eslint": "^10.6.0",
@@ -113,8 +111,7 @@
"uuid": "^14.0.1",
"vite": "^7.3.2",
"vite-plugin-istanbul": "^9.0.1",
"vitest": "^4.1.10",
"vitest-browser-react": "^2.2.0"
"vitest": "^4.1.10"
}
},
"node_modules/@babel/code-frame": {
@@ -438,13 +435,6 @@
"node": ">=18"
}
},
"node_modules/@blazediff/core": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@blazediff/core/-/core-1.9.1.tgz",
"integrity": "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==",
"dev": true,
"license": "MIT"
},
"node_modules/@cacheable/memory": {
"version": "2.0.9",
"resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.0.9.tgz",
@@ -2188,13 +2178,6 @@
"node": ">=18"
}
},
"node_modules/@polka/url": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
"integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
"dev": true,
"license": "MIT"
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
@@ -3584,63 +3567,6 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/@vitest/browser": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.10.tgz",
"integrity": "sha512-UDwuWGwXj646CBx/bQHOaJSX7np0I8JL/UKQYa1e4QrVHH8VdWtx8eaOuf8sy0ShwDgR6NjJAsp5eF6vjF6qng==",
"dev": true,
"license": "MIT",
"dependencies": {
"@blazediff/core": "1.9.1",
"@vitest/mocker": "4.1.10",
"@vitest/utils": "4.1.10",
"magic-string": "^0.30.21",
"pngjs": "^7.0.0",
"sirv": "^3.0.2",
"tinyrainbow": "^3.1.0",
"ws": "^8.19.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"vitest": "4.1.10"
}
},
"node_modules/@vitest/browser-playwright": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.1.10.tgz",
"integrity": "sha512-nMoXGEiRpT7m3W7NsbvrM2aKNwiNHZf+zEpUCvMteGjZFvfT96Q9fh7QyB98dvDWXiKvrLxA7bJ1mCOOv+JQPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/browser": "4.1.10",
"@vitest/mocker": "4.1.10",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"playwright": "*",
"vitest": "4.1.10"
},
"peerDependenciesMeta": {
"playwright": {
"optional": false
}
}
},
"node_modules/@vitest/browser/node_modules/pngjs": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
"integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.19.0"
}
},
"node_modules/@vitest/coverage-v8": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz",
@@ -9295,16 +9221,6 @@
"mkdirp": "bin/cmd.js"
}
},
"node_modules/mrmime": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
"integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -11551,21 +11467,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/sirv": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
"integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@polka/url": "^1.0.0-next.24",
"mrmime": "^2.0.0",
"totalist": "^3.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/slugify": {
"version": "1.6.9",
"resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.9.tgz",
@@ -12437,16 +12338,6 @@
"node": ">=8.0"
}
},
"node_modules/totalist": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/trim-lines": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
@@ -13622,31 +13513,6 @@
}
}
},
"node_modules/vitest-browser-react": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/vitest-browser-react/-/vitest-browser-react-2.2.0.tgz",
"integrity": "sha512-oY3KM6305kwJMa6nHo92vVtkOsih7mjEf12dLKuphaF+9ywWPEc+qanIBd394SZ6m5LadVEaG6dicvvizOzmjA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@types/react": "^18.0.0 || ^19.0.0",
"@types/react-dom": "^18.0.0 || ^19.0.0",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0",
"vitest": "^4.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
@@ -13834,28 +13700,6 @@
"typedarray-to-buffer": "^3.1.5"
}
},
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xml-utils": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/xml-utils/-/xml-utils-1.10.2.tgz",
+1 -4
View File
@@ -124,8 +124,6 @@
"@types/string-hash": "^1.1.3",
"@types/wicg-file-system-access": "^2023.10.7",
"@vitejs/plugin-react": "5.2",
"@vitest/browser": "^4.1.10",
"@vitest/browser-playwright": "^4.1.10",
"@vitest/coverage-v8": "^4.1.10",
"cors": "^2.8.6",
"eslint": "^10.6.0",
@@ -147,7 +145,6 @@
"uuid": "^14.0.1",
"vite": "^7.3.2",
"vite-plugin-istanbul": "^9.0.1",
"vitest": "^4.1.10",
"vitest-browser-react": "^2.2.0"
"vitest": "^4.1.10"
}
}
@@ -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");
});
+24
View File
@@ -0,0 +1,24 @@
import { describe, it, expect } from "vitest";
import type { StyleSpecification } from "maplibre-gl";
import { undoMessages, redoMessages } from "./diffmessage";
const before = { version: 8, sources: {}, layers: [] } as StyleSpecification;
const after = {
version: 8,
sources: {},
layers: [{ id: "bg", type: "background" }],
} as StyleSpecification;
describe("diff messages", () => {
it("prefixes undo messages with 'Undo'", () => {
const messages = undoMessages(before, after);
expect(messages.length).toBeGreaterThan(0);
expect(messages.every((m) => m.startsWith("Undo "))).toBe(true);
});
it("prefixes redo messages with 'Redo'", () => {
const messages = redoMessages(before, after);
expect(messages.length).toBeGreaterThan(0);
expect(messages.every((m) => m.startsWith("Redo "))).toBe(true);
});
});
+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 type { Map } from "maplibre-gl";
import LayerWatcher from "./layerwatcher";
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");
});
});
+33
View File
@@ -0,0 +1,33 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { downloadGlyphsMetadata, downloadSpriteMetadata } from "./metadata";
afterEach(() => vi.restoreAllMocks());
describe("downloadGlyphsMetadata", () => {
it("returns an empty list for an empty url", async () => {
expect(await downloadGlyphsMetadata("")).toEqual([]);
});
it("fetches the fontstacks list and de-duplicates it", async () => {
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true, json: async () => ["A", "A", "B"] })));
const fonts = await downloadGlyphsMetadata("https://example.com/{fontstack}/{range}.pbf");
expect(fonts.sort()).toEqual(["A", "B"]);
});
it("returns the default on a failed request", async () => {
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: false })));
expect(await downloadGlyphsMetadata("https://example.com/x/{fontstack}/{range}.pbf")).toEqual([]);
});
});
describe("downloadSpriteMetadata", () => {
it("returns the sprite icon names", async () => {
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true, json: async () => ({ airport: {}, park: {} }) })));
const icons = await downloadSpriteMetadata("https://example.com/sprite");
expect(icons.sort()).toEqual(["airport", "park"]);
});
it("returns an empty list for an empty base url", async () => {
expect(await downloadSpriteMetadata("")).toEqual([]);
});
});
+34
View File
@@ -0,0 +1,34 @@
import { describe, it, expect } from "vitest";
import { RevisionStore } from "./revisions";
const rev = (id: string) => ({ version: 8, id, sources: {}, layers: [] }) as any;
describe("RevisionStore", () => {
it("tracks latest/current as revisions are added", () => {
const store = new RevisionStore();
store.addRevision(rev("a"));
store.addRevision(rev("b"));
expect(store.latest.id).toBe("b");
expect(store.current.id).toBe("b");
});
it("undo and redo move through history", () => {
const store = new RevisionStore();
store.addRevision(rev("a"));
store.addRevision(rev("b"));
expect(store.undo().id).toBe("a");
expect(store.undo().id).toBe("a"); // clamped at start
expect(store.redo().id).toBe("b");
expect(store.redo().id).toBe("b"); // clamped at end
});
it("clears redo history when a new revision is added after undo", () => {
const store = new RevisionStore();
store.addRevision(rev("a"));
store.addRevision(rev("b"));
store.undo();
store.addRevision(rev("c"));
expect(store.latest.id).toBe("c");
expect(store.redo().id).toBe("c");
});
});
+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);
});
});
+23
View File
@@ -0,0 +1,23 @@
import { describe, it, expect } from "vitest";
import { addSource, changeSource, deleteSource } from "./source";
const style = { version: 8, id: "s", sources: { a: { type: "vector" } }, layers: [] } as any;
describe("source helpers", () => {
it("adds a source", () => {
const result = addSource(style, "b", { type: "geojson", data: {} } as any);
expect(result.sources.b).toEqual({ type: "geojson", data: {} });
expect(result.sources.a).toBeDefined();
});
it("changes an existing source", () => {
const result = changeSource(style, "a", { type: "raster" } as any);
expect(result.sources.a).toEqual({ type: "raster" });
});
it("deletes a source without mutating the input", () => {
const result = deleteSource(style, "a");
expect(result.sources.a).toBeUndefined();
expect(style.sources.a).toBeDefined();
});
});
+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" })
);
});
});
+65
View File
@@ -0,0 +1,65 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
// StyleStore reads/writes window.localStorage; provide a minimal in-memory mock.
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() });
// Avoid network in loadDefaultStyle.
vi.mock("../urlopen", () => ({
loadStyleUrl: vi.fn(async () => ({ version: 8, id: "default", sources: {}, layers: [] })),
}));
import { StyleStore } from "./stylestore";
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();
await 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();
await 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");
});
});
+120
View File
@@ -0,0 +1,120 @@
import { describe, it, expect } from "vitest";
import type { StyleSpecification } from "maplibre-gl";
import style from "./style";
function baseStyle(overrides: Partial<StyleSpecification> = {}): StyleSpecification {
return { version: 8, sources: {}, layers: [], ...overrides } as StyleSpecification;
}
describe("ensureStyleValidity", () => {
it("adds an id and strips interactive from layers", () => {
const result = style.ensureStyleValidity(
baseStyle({
layers: [{ id: "l", type: "background", interactive: true } as any],
})
);
expect(result.id).toBeTruthy();
expect("interactive" in result.layers[0]).toBe(false);
});
it("keeps an existing id", () => {
const result = style.ensureStyleValidity(baseStyle({ id: "keep-me" } as any));
expect(result.id).toBe("keep-me");
});
});
describe("generateId", () => {
it("generates a non-empty string", () => {
expect(typeof style.generateId()).toBe("string");
expect(style.generateId().length).toBeGreaterThan(0);
});
});
describe("indexOfLayer", () => {
const layers = [{ id: "a" }, { id: "b" }] as any;
it("returns the index of a matching layer", () => {
expect(style.indexOfLayer(layers, "b")).toBe(1);
});
it("returns null when not found", () => {
expect(style.indexOfLayer(layers, "missing")).toBeNull();
});
});
describe("getAccessToken", () => {
it("reads the token from metadata", () => {
const s = baseStyle({ metadata: { "maputnik:openmaptiles_access_token": "abc" } } as any);
expect(style.getAccessToken("openmaptiles", s, {})).toBe("abc");
});
it("falls back to the bundled token only when allowed", () => {
const s = baseStyle();
expect(style.getAccessToken("openmaptiles", s, {})).toBeUndefined();
expect(style.getAccessToken("openmaptiles", s, { allowFallback: true })).toBeTruthy();
});
});
describe("replaceAccessTokens", () => {
it("replaces {key} in a source url and in glyphs", () => {
const s = baseStyle({
metadata: { "maputnik:openmaptiles_access_token": "TОKEN" } as any,
sources: { openmaptiles: { type: "vector", url: "https://api.maptiler.com/x?key={key}" } } as any,
glyphs: "https://api.maptiler.com/fonts/{fontstack}/{range}.pbf?key={key}",
});
const result = style.replaceAccessTokens(s);
expect((result.sources.openmaptiles as any).url).toContain("key=TОKEN");
expect(result.glyphs).toContain("key=TОKEN");
});
it("maps thunderforest transport/outdoors sources to the thunderforest token", () => {
const s = baseStyle({
metadata: { "maputnik:thunderforest_access_token": "TF" } as any,
sources: { thunderforest_transport: { type: "vector", url: "https://tile.thunderforest.com/x?apikey={key}" } } as any,
});
const result = style.replaceAccessTokens(s);
expect((result.sources.thunderforest_transport as any).url).toContain("TF");
});
it("appends an api_key query param for stadia sources", () => {
const s = baseStyle({
metadata: { "maputnik:stadia_access_token": "ST" } as any,
sources: { basemap: { type: "vector", url: "https://tiles.stadiamaps.com/data/x.json" } } as any,
});
const result = style.replaceAccessTokens(s);
expect((result.sources.basemap as any).url).toContain("api_key=ST");
});
it("uses the locationiq token for locationiq sources", () => {
const s = baseStyle({
metadata: { "maputnik:locationiq_access_token": "LIQ" } as any,
sources: { liq: { type: "vector", url: "https://tiles.locationiq.com/v3/x?key={key}" } } as any,
});
const result = style.replaceAccessTokens(s);
expect((result.sources.liq as any).url).toContain("LIQ");
});
it("leaves sources without a url or token untouched", () => {
const s = baseStyle({
sources: {
noUrl: { type: "geojson", data: {} } as any,
noToken: { type: "vector", url: "https://api.maptiler.com/x?key={key}" } as any,
},
});
const result = style.replaceAccessTokens(s);
expect((result.sources.noToken as any).url).toContain("{key}");
});
});
describe("stripAccessTokens", () => {
it("removes provider access tokens from metadata", () => {
const s = baseStyle({
metadata: {
"maputnik:openmaptiles_access_token": "a",
"maputnik:thunderforest_access_token": "b",
"maputnik:renderer": "mlgljs",
} as any,
});
const result = style.stripAccessTokens(s);
expect(result.metadata).not.toHaveProperty("maputnik:openmaptiles_access_token");
expect(result.metadata).not.toHaveProperty("maputnik:thunderforest_access_token");
expect((result.metadata as any)["maputnik:renderer"]).toBe("mlgljs");
});
});
+1 -27
View File
@@ -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"],