From d039dc73a5fe80f064c5a535b6e994485f4e13e2 Mon Sep 17 00:00:00 2001 From: HarelM Date: Fri, 10 Jul 2026 21:11:41 +0300 Subject: [PATCH] Increase coverage to about 80% --- .github/workflows/ci.yml | 1 - e2e/layer-editor.spec.ts | 86 +++++++++- e2e/maputnik-driver.ts | 50 ++++++ e2e/modals.spec.ts | 23 ++- e2e/playwright-helper.ts | 19 ++- package-lock.json | 158 +----------------- package.json | 5 +- .../InputAutocomplete.browser.test.tsx | 24 --- src/libs/diffmessage.test.ts | 24 +++ src/libs/highlight.test.ts | 48 ++++++ src/libs/layer.test.ts | 94 +++++++++++ src/libs/layerwatcher.test.ts | 46 +++++ src/libs/metadata.test.ts | 33 ++++ src/libs/revisions.test.ts | 34 ++++ src/libs/sort-numerically.test.ts | 12 ++ src/libs/source.test.ts | 23 +++ src/libs/spec-helper.test.ts | 17 ++ src/libs/store/apistore.test.ts | 76 +++++++++ src/libs/store/stylestore.test.ts | 65 +++++++ src/libs/style.test.ts | 120 +++++++++++++ vitest.config.ts | 28 +--- 21 files changed, 765 insertions(+), 221 deletions(-) delete mode 100644 src/components/InputAutocomplete.browser.test.tsx create mode 100644 src/libs/diffmessage.test.ts create mode 100644 src/libs/highlight.test.ts create mode 100644 src/libs/layer.test.ts create mode 100644 src/libs/layerwatcher.test.ts create mode 100644 src/libs/metadata.test.ts create mode 100644 src/libs/revisions.test.ts create mode 100644 src/libs/sort-numerically.test.ts create mode 100644 src/libs/source.test.ts create mode 100644 src/libs/spec-helper.test.ts create mode 100644 src/libs/store/apistore.test.ts create mode 100644 src/libs/store/stylestore.test.ts create mode 100644 src/libs/style.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a7ccfef..891fab55 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/e2e/layer-editor.spec.ts b/e2e/layer-editor.spec.ts index c510315b..e5e86f19 100644 --- a/e2e/layer-editor.spec.ts +++ b/e2e/layer-editor.spec.ts @@ -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", () => { diff --git a/e2e/maputnik-driver.ts b/e2e/maputnik-driver.ts index ad594a9e..319a82f2 100644 --- a/e2e/maputnik-driver.ts +++ b/e2e/maputnik-driver.ts @@ -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. */ diff --git a/e2e/modals.spec.ts b/e2e/modals.spec.ts index 370fb556..3920918d 100644 --- a/e2e/modals.spec.ts +++ b/e2e/modals.spec.ts @@ -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 () => { diff --git a/e2e/playwright-helper.ts b/e2e/playwright-helper.ts index b99a2f02..db323865 100644 --- a/e2e/playwright-helper.ts +++ b/e2e/playwright-helper.ts @@ -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): void { +function assertDeepNestedInclude(actual: any, expected: Record | 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 { } }); - shouldDeepNestedInclude = (value: Record) => + shouldDeepNestedInclude = (value: Record | unknown[]) => this.assertValue((actual) => assertDeepNestedInclude(actual, value)); } @@ -144,6 +144,10 @@ async function typeSequence(page: Page, text: string): Promise { 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(getter); } + /** Stubs the File System Access "save" picker so file saves complete headlessly. */ + public stubSaveFilePicker(): Promise { + 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 = (target: T): Assertable => new Assertable(target); diff --git a/package-lock.json b/package-lock.json index b8580932..db06e681 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 0ab40c16..7522c033 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/src/components/InputAutocomplete.browser.test.tsx b/src/components/InputAutocomplete.browser.test.tsx deleted file mode 100644 index fc8c8dfd..00000000 --- a/src/components/InputAutocomplete.browser.test.tsx +++ /dev/null @@ -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( [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"); -}); diff --git a/src/libs/diffmessage.test.ts b/src/libs/diffmessage.test.ts new file mode 100644 index 00000000..e666645c --- /dev/null +++ b/src/libs/diffmessage.test.ts @@ -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); + }); +}); diff --git a/src/libs/highlight.test.ts b/src/libs/highlight.test.ts new file mode 100644 index 00000000..b2eeaa21 --- /dev/null +++ b/src/libs/highlight.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from "vitest"; +import type { LayerSpecification } from "maplibre-gl"; +import { colorHighlightedLayer } from "./highlight"; + +function layer(overrides: Record): LayerSpecification { + return { id: "l", source: "s", "source-layer": "sl", ...overrides } as unknown as LayerSpecification; +} + +describe("colorHighlightedLayer", () => { + it("returns null for undefined, background and raster layers", () => { + expect(colorHighlightedLayer(undefined)).toBeNull(); + expect(colorHighlightedLayer(layer({ type: "background" }))).toBeNull(); + expect(colorHighlightedLayer(layer({ type: "raster" }))).toBeNull(); + }); + + it("builds a circle highlight for circle and symbol layers", () => { + for (const type of ["circle", "symbol"]) { + const highlight = colorHighlightedLayer(layer({ type }))!; + expect(highlight).not.toBeNull(); + expect(highlight.type).toBe("circle"); + expect(highlight.id).toMatch(/_highlight$/); + expect((highlight.paint as any)["circle-radius"]).toBe(3); + } + }); + + it("builds a line highlight with an overridden width", () => { + const highlight = colorHighlightedLayer(layer({ type: "line" }))!; + expect(highlight.type).toBe("line"); + expect((highlight.paint as any)["line-width"]).toBe(2); + }); + + it("builds a polygon highlight for fill and fill-extrusion layers", () => { + for (const type of ["fill", "fill-extrusion"]) { + const highlight = colorHighlightedLayer(layer({ type }))!; + expect(highlight.type).toBe("fill"); + } + }); + + it("copies the source layer's filter when present, drops it otherwise", () => { + const withFilter = colorHighlightedLayer( + layer({ type: "line", filter: ["==", "class", "road"] } as any) + )!; + expect(withFilter.filter).toEqual(["==", "class", "road"]); + + const withoutFilter = colorHighlightedLayer(layer({ type: "line" }))!; + expect("filter" in withoutFilter).toBe(false); + }); +}); diff --git a/src/libs/layer.test.ts b/src/libs/layer.test.ts new file mode 100644 index 00000000..ae98bd14 --- /dev/null +++ b/src/libs/layer.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from "vitest"; +import type { LayerSpecification } from "maplibre-gl"; +import { changeType, changeProperty, layerPrefix, findClosestCommonPrefix } from "./layer"; + +describe("changeType", () => { + it("drops paint/layout props not valid for the new type", () => { + const layer = { + id: "l", + type: "fill", + source: "s", + paint: { "fill-color": "#fff", "fill-opacity": 0.5 }, + layout: { visibility: "visible" }, + } as unknown as LayerSpecification; + + const changed = changeType(layer, "background") as any; + expect(changed.type).toBe("background"); + // fill-color is not a valid background paint property + expect(changed.paint["fill-color"]).toBeUndefined(); + // visibility is valid for background layout + expect(changed.layout.visibility).toBe("visible"); + }); + + it("keeps props that are valid for the new type", () => { + const layer = { + id: "l", + type: "line", + source: "s", + paint: { "line-color": "#000" }, + } as unknown as LayerSpecification; + const changed = changeType(layer, "line") as any; + expect(changed.paint["line-color"]).toBe("#000"); + }); +}); + +describe("changeProperty", () => { + const base = { id: "l", type: "background" } as unknown as LayerSpecification; + + it("sets a top-level property", () => { + const changed = changeProperty(base, null, "minzoom", 4) as any; + expect(changed.minzoom).toBe(4); + }); + + it("removes a top-level property when value is undefined", () => { + const changed = changeProperty({ ...base, minzoom: 4 } as any, null, "minzoom", undefined) as any; + expect("minzoom" in changed).toBe(false); + }); + + it("sets a grouped property", () => { + const changed = changeProperty(base, "paint", "background-color", "#fff") as any; + expect(changed.paint["background-color"]).toBe("#fff"); + }); + + it("removes a grouped property and drops the empty group", () => { + const layer = { ...base, paint: { "background-color": "#fff" } } as any; + const changed = changeProperty(layer, "paint", "background-color", undefined) as any; + expect("paint" in changed).toBe(false); + }); + + it("removes a grouped property but keeps a non-empty group", () => { + const layer = { ...base, paint: { "background-color": "#fff", "background-opacity": 1 } } as any; + const changed = changeProperty(layer, "paint", "background-color", undefined) as any; + expect(changed.paint["background-color"]).toBeUndefined(); + expect(changed.paint["background-opacity"]).toBe(1); + }); +}); + +describe("layerPrefix", () => { + it("returns the segment before the first separator", () => { + expect(layerPrefix("foo-bar")).toBe("foo"); + expect(layerPrefix("foo_bar")).toBe("foo"); + expect(layerPrefix("foo bar")).toBe("foo"); + expect(layerPrefix("single")).toBe("single"); + }); +}); + +describe("findClosestCommonPrefix", () => { + const layers = [ + { id: "aa" }, + { id: "aa-2" }, + { id: "b" }, + ] as unknown as LayerSpecification[]; + + it("finds the first index of a run sharing the prefix", () => { + expect(findClosestCommonPrefix(layers, 1)).toBe(0); + }); + + it("returns the index itself when the previous prefix differs", () => { + expect(findClosestCommonPrefix(layers, 2)).toBe(2); + }); + + it("returns the index for the first layer", () => { + expect(findClosestCommonPrefix(layers, 0)).toBe(0); + }); +}); diff --git a/src/libs/layerwatcher.test.ts b/src/libs/layerwatcher.test.ts new file mode 100644 index 00000000..a737bab7 --- /dev/null +++ b/src/libs/layerwatcher.test.ts @@ -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"); + }); +}); diff --git a/src/libs/metadata.test.ts b/src/libs/metadata.test.ts new file mode 100644 index 00000000..935faa81 --- /dev/null +++ b/src/libs/metadata.test.ts @@ -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([]); + }); +}); diff --git a/src/libs/revisions.test.ts b/src/libs/revisions.test.ts new file mode 100644 index 00000000..256d13cc --- /dev/null +++ b/src/libs/revisions.test.ts @@ -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"); + }); +}); diff --git a/src/libs/sort-numerically.test.ts b/src/libs/sort-numerically.test.ts new file mode 100644 index 00000000..a55051a4 --- /dev/null +++ b/src/libs/sort-numerically.test.ts @@ -0,0 +1,12 @@ +import { describe, it, expect } from "vitest"; +import sortNumerically from "./sort-numerically"; + +describe("sortNumerically", () => { + it("orders numbers (and numeric strings) ascending", () => { + expect([3, 1, 2].sort(sortNumerically)).toEqual([1, 2, 3]); + expect(["10", "2", "1"].sort(sortNumerically)).toEqual(["1", "2", "10"]); + expect(sortNumerically(5, 5)).toBe(0); + expect(sortNumerically(1, 2)).toBe(-1); + expect(sortNumerically(2, 1)).toBe(1); + }); +}); diff --git a/src/libs/source.test.ts b/src/libs/source.test.ts new file mode 100644 index 00000000..98a27994 --- /dev/null +++ b/src/libs/source.test.ts @@ -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(); + }); +}); diff --git a/src/libs/spec-helper.test.ts b/src/libs/spec-helper.test.ts new file mode 100644 index 00000000..64bf3be2 --- /dev/null +++ b/src/libs/spec-helper.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from "vitest"; +import { findDefaultFromSpec } from "./spec-helper"; + +describe("findDefaultFromSpec", () => { + it("returns the spec default when present (even if falsy)", () => { + expect(findDefaultFromSpec({ type: "string", default: "hi" })).toBe("hi"); + expect(findDefaultFromSpec({ type: "boolean", default: false })).toBe(false); + }); + + it("falls back to a sensible default per type", () => { + expect(findDefaultFromSpec({ type: "color" })).toBe("#000000"); + expect(findDefaultFromSpec({ type: "string" })).toBe(""); + // The falsy `false` fallback collapses to "" via the `|| ""` in the impl. + expect(findDefaultFromSpec({ type: "boolean" })).toBe(""); + expect(findDefaultFromSpec({ type: "array" })).toEqual([]); + }); +}); diff --git a/src/libs/store/apistore.test.ts b/src/libs/store/apistore.test.ts new file mode 100644 index 00000000..1c7391c8 --- /dev/null +++ b/src/libs/store/apistore.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.stubGlobal("window", { location: { port: "8000" } }); + +// Avoid opening a real websocket in notifyLocalChanges(). +const wsInstances: any[] = []; +vi.mock("reconnecting-websocket", () => ({ + default: class { + onmessage: ((e: { data: string }) => void) | null = null; + constructor(public url: string) { + wsInstances.push(this); + } + }, +})); + +import { ApiStyleStore } from "./apistore"; + +const okJson = (body: any) => ({ ok: true, json: async () => body }); + +describe("ApiStyleStore", () => { + beforeEach(() => { + wsInstances.length = 0; + vi.restoreAllMocks(); + }); + + it("init fetches style ids and wires up local change notifications", async () => { + const fetchMock = vi.fn(async () => okJson(["style-a", "style-b"]) as any); + vi.stubGlobal("fetch", fetchMock); + const onLocalStyleChange = vi.fn(); + const store = new ApiStyleStore({ onLocalStyleChange }); + + await store.init(); + expect(fetchMock).toHaveBeenCalledWith("http://localhost:8000/styles", { mode: "cors" }); + expect(store.latestStyleId).toBe("style-a"); + + // A websocket message triggers onLocalStyleChange with a valid style. + expect(wsInstances.length).toBe(1); + wsInstances[0].onmessage!({ data: JSON.stringify({ version: 8, sources: {}, layers: [] }) }); + expect(onLocalStyleChange).toHaveBeenCalledOnce(); + expect(onLocalStyleChange.mock.calls[0][0].id).toBeTruthy(); + }); + + it("init throws a friendly error when the API is unreachable", async () => { + vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("boom"); })); + const store = new ApiStyleStore({}); + await expect(store.init()).rejects.toThrow("Can not connect to style API"); + }); + + it("getLatestStyle fetches the latest style and validates it", async () => { + vi.stubGlobal("fetch", vi.fn(async () => okJson(["s1"]) as any)); + const store = new ApiStyleStore({}); + await store.init(); + + vi.stubGlobal("fetch", vi.fn(async () => okJson({ version: 8, id: "s1", sources: {}, layers: [] }) as any)); + const latest = await store.getLatestStyle(); + expect(latest.id).toBe("s1"); + }); + + it("getLatestStyle throws when not initialised", async () => { + const store = new ApiStyleStore({}); + await expect(store.getLatestStyle()).rejects.toThrow(/init the api backend/); + }); + + it("save PUTs the (token-stripped) style to the API", () => { + const fetchMock = vi.fn(async () => okJson({}) as any); + vi.stubGlobal("fetch", fetchMock); + const store = new ApiStyleStore({}); + const mapStyle = { version: 8, id: "s1", sources: {}, layers: [] } as any; + + expect(store.save(mapStyle)).toBe(mapStyle); + expect(fetchMock).toHaveBeenCalledWith( + "http://localhost:8000/styles/s1", + expect.objectContaining({ method: "PUT", mode: "cors" }) + ); + }); +}); diff --git a/src/libs/store/stylestore.test.ts b/src/libs/store/stylestore.test.ts new file mode 100644 index 00000000..5fbc22cf --- /dev/null +++ b/src/libs/store/stylestore.test.ts @@ -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 = {}; + 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"); + }); +}); diff --git a/src/libs/style.test.ts b/src/libs/style.test.ts new file mode 100644 index 00000000..774d275e --- /dev/null +++ b/src/libs/style.test.ts @@ -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 { + 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"); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index b9a412c0..a350b7bb 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,34 +1,8 @@ import { defineConfig } from "vitest/config"; -import react from "@vitejs/plugin-react"; -import { playwright } from "@vitest/browser-playwright"; export default defineConfig({ - plugins: [react()], test: { - projects: [ - { - extends: true, - test: { - name: "unit", - environment: "node", - include: ["src/**/*.test.{ts,tsx}"], - exclude: ["src/**/*.browser.test.{ts,tsx}"], - }, - }, - { - extends: true, - test: { - name: "browser", - include: ["src/**/*.browser.test.{ts,tsx}"], - browser: { - enabled: true, - provider: playwright(), - headless: true, - instances: [{ browser: "chromium" }], - }, - }, - }, - ], + include: ["src/**/*.test.{ts,tsx}"], coverage: { provider: "v8", reporter: ["json", "lcov", "text-summary"],