Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot] b7f9753ff7 chore(deps-dev): Bump vite from 7.3.2 to 8.1.3
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.3.2 to 8.1.3.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.1.3/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.1.3
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-10 18:21:07 +00:00
21 changed files with 765 additions and 1580 deletions
+1
View File
@@ -96,6 +96,7 @@ 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
+2 -115
View File
@@ -76,15 +76,6 @@ describe("layer editor", () => {
layers: [{ id: "background:" + bgId, type: "background", minzoom: 1 }],
});
});
test("the range slider adjusts min-zoom", async () => {
// The slider starts at 1 (set via the text input above); one step right lands on 2.
await when.focus("min-zoom.input-range");
await when.typeKeys("{rightarrow}");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id: "background:" + bgId, type: "background", minzoom: 2 }],
});
});
});
describe("max-zoom", () => {
@@ -154,13 +145,6 @@ 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()).shouldDeepNestedInclude({
layers: [{ id: "background:" + bgId, type: "background", paint: { "background-color": "#ff0000" } }],
});
});
});
describe("opacity", () => {
@@ -182,105 +166,8 @@ describe("layer editor", () => {
});
describe("filter", () => {
test("compound filter", async () => {
const id = await when.modal.fillLayers({ type: "fill", layer: "example" });
await when.addFilter();
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, type: "fill", source: "example", filter: ["all", ["==", "name", ""]] }],
});
// Changing the operator updates the compound filter.
await when.selectFilterOperator("!=");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, type: "fill", source: "example", filter: ["all", ["!=", "name", ""]] }],
});
// A second filter item extends the compound filter.
await when.addFilter();
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, type: "fill", source: "example", filter: ["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()).shouldDeepNestedInclude({
layers: [{ id, type: "circle", source: "example", paint: { "circle-radius": { stops: [[6, 5], [10, 5]] } } }],
});
await when.addFunctionStop("circle-radius");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, type: "circle", source: "example", paint: { "circle-radius": { stops: [[6, 5], [10, 5], [11, 5]] } } }],
});
// Deleting the first stop leaves the rest.
await when.deleteFunctionStop("circle-radius");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, type: "circle", source: "example", paint: { "circle-radius": { stops: [[10, 5], [11, 5]] } } }],
});
});
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()).shouldDeepNestedInclude({
layers: [
{
id,
type: "circle",
source: "example",
paint: {
"circle-blur": {
property: "",
type: "exponential",
stops: [[{ zoom: 6, value: 0 }, 1], [{ zoom: 10, value: 0 }, 1]],
},
},
},
],
});
await when.addFunctionStop("circle-blur");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id,
type: "circle",
source: "example",
paint: {
"circle-blur": {
property: "",
type: "exponential",
stops: [[{ zoom: 6, value: 0 }, 1], [{ zoom: 10, value: 0 }, 1], [{ zoom: 11, value: 0 }, 1]],
},
},
},
],
});
await when.deleteFunctionStop("circle-blur");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id,
type: "circle",
source: "example",
paint: {
"circle-blur": {
property: "",
type: "exponential",
stops: [[{ zoom: 10, value: 0 }, 1], [{ zoom: 11, value: 0 }, 1]],
},
},
},
],
});
});
test.skip("expand/collapse", () => {});
test.skip("compound filter", () => {});
});
describe("layout", () => {
-50
View File
@@ -158,56 +158,6 @@ 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. */
+21 -38
View File
@@ -64,15 +64,8 @@ describe("modals", () => {
await then(get.elementByTestId("modal:export")).shouldNotExist();
});
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();
});
// TODO: Work out how to download a file and check the contents
test.skip("download", () => {});
});
describe("sources", () => {
@@ -81,17 +74,7 @@ describe("modals", () => {
await when.click("nav: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("active sources", () => {});
test.skip("public source", () => {});
test("add new source", async () => {
@@ -101,8 +84,8 @@ describe("modals", () => {
await when.select("modal:sources.add.scheme_type", "tms");
await when.click("modal:sources.add.add_source");
await when.wait(200);
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
sources: { [sourceId]: { scheme: "tms" } },
await then(get.styleFromLocalStorage().then((style) => style.sources[sourceId])).shouldInclude({
scheme: "tms",
});
});
@@ -135,8 +118,8 @@ describe("modals", () => {
await when.setValue("modal:sources.add.tile_size", "128");
await when.click("modal:sources.add.add_source");
await when.wait(200);
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
sources: { [sourceId]: { tileSize: 128 } },
await then(get.styleFromLocalStorage().then((style) => style.sources[sourceId])).shouldInclude({
tileSize: 128,
});
});
});
@@ -221,8 +204,8 @@ describe("modals", () => {
const apiKey = "testing123";
await when.setValue("modal:settings.maputnik:openmaptiles_access_token", apiKey);
await when.click("modal:settings.name");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
metadata: { "maputnik:openmaptiles_access_token": apiKey },
await then(get.styleFromLocalStorage().then((style) => style.metadata)).shouldInclude({
"maputnik:openmaptiles_access_token": apiKey,
});
});
@@ -230,8 +213,8 @@ describe("modals", () => {
const apiKey = "testing123";
await when.setValue("modal:settings.maputnik:thunderforest_access_token", apiKey);
await when.click("modal:settings.name");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
metadata: { "maputnik:thunderforest_access_token": apiKey },
await then(get.styleFromLocalStorage().then((style) => style.metadata)).shouldInclude({
"maputnik:thunderforest_access_token": apiKey,
});
});
@@ -239,8 +222,8 @@ describe("modals", () => {
const apiKey = "testing123";
await when.setValue("modal:settings.maputnik:stadia_access_token", apiKey);
await when.click("modal:settings.name");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
metadata: { "maputnik:stadia_access_token": apiKey },
await then(get.styleFromLocalStorage().then((style) => style.metadata)).shouldInclude({
"maputnik:stadia_access_token": apiKey,
});
});
@@ -248,29 +231,29 @@ describe("modals", () => {
const apiKey = "testing123";
await when.setValue("modal:settings.maputnik:locationiq_access_token", apiKey);
await when.click("modal:settings.name");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
metadata: { "maputnik:locationiq_access_token": apiKey },
await then(get.styleFromLocalStorage().then((style) => style.metadata)).shouldInclude({
"maputnik:locationiq_access_token": apiKey,
});
});
test("style projection mercator", async () => {
await when.select("modal:settings.projection", "mercator");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
projection: { type: "mercator" },
await then(get.styleFromLocalStorage().then((style) => style.projection)).shouldInclude({
type: "mercator",
});
});
test("style projection globe", async () => {
await when.select("modal:settings.projection", "globe");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
projection: { type: "globe" },
await then(get.styleFromLocalStorage().then((style) => style.projection)).shouldInclude({
type: "globe",
});
});
test("style projection vertical-perspective", async () => {
await when.select("modal:settings.projection", "vertical-perspective");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
projection: { type: "vertical-perspective" },
await then(get.styleFromLocalStorage().then((style) => style.projection)).shouldInclude({
type: "vertical-perspective",
});
});
+6 -17
View File
@@ -62,12 +62,11 @@ function isLocator(target: unknown): target is Locator {
);
}
/**
* Asserts that `actual` recursively contains everything in `expected`: nested
* objects are matched as subsets, while arrays and primitives must match exactly.
*/
function assertDeepNestedInclude(actual: any, expected: Record<string, unknown> | unknown[]): void {
expect(actual).toMatchObject(expected);
/** Asserts that every top-level key in `expected` deep-equals its counterpart in `actual`. */
function assertDeepNestedInclude(actual: any, expected: Record<string, unknown>): void {
for (const key of Object.keys(expected)) {
expect(actual?.[key], `property "${key}"`).toEqual(expected[key]);
}
}
/**
@@ -131,7 +130,7 @@ export class Assertable<T> {
}
});
shouldDeepNestedInclude = (value: Record<string, unknown> | unknown[]) =>
shouldDeepNestedInclude = (value: Record<string, unknown>) =>
this.assertValue((actual) => assertDeepNestedInclude(actual, value));
}
@@ -145,7 +144,6 @@ async function typeSequence(page: Page, text: string): Promise<void> {
del: "Delete",
tab: "Tab",
home: "Home",
rightarrow: "ArrowRight",
};
for (let i = 0; i < tokens.length; i++) {
@@ -204,15 +202,6 @@ 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);
+679 -765
View File
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -124,6 +124,8 @@
"@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",
@@ -143,8 +145,9 @@
"typescript": "^6.0.3",
"typescript-eslint": "^8.62.1",
"uuid": "^14.0.1",
"vite": "^7.3.2",
"vite": "^8.1.3",
"vite-plugin-istanbul": "^9.0.1",
"vitest": "^4.1.10"
"vitest": "^4.1.10",
"vitest-browser-react": "^2.2.0"
}
}
@@ -0,0 +1,24 @@
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
@@ -1,24 +0,0 @@
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
@@ -1,48 +0,0 @@
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
@@ -1,94 +0,0 @@
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
@@ -1,46 +0,0 @@
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
@@ -1,33 +0,0 @@
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
@@ -1,34 +0,0 @@
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
@@ -1,12 +0,0 @@
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
@@ -1,23 +0,0 @@
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
@@ -1,17 +0,0 @@
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
@@ -1,76 +0,0 @@
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
@@ -1,65 +0,0 @@
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
@@ -1,120 +0,0 @@
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");
});
});
+27 -1
View File
@@ -1,8 +1,34 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import { playwright } from "@vitest/browser-playwright";
export default defineConfig({
plugins: [react()],
test: {
include: ["src/**/*.test.{ts,tsx}"],
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" }],
},
},
},
],
coverage: {
provider: "v8",
reporter: ["json", "lcov", "text-summary"],