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/AGENTS.md b/AGENTS.md index 501d2edb..637642b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,7 @@ Then run the end-to-end tests (Playwright starts the dev server automatically): npm run test ``` -Run the unit and component tests with Vitest: +Run the unit tests with Vitest: ``` npm run test-unit @@ -45,3 +45,106 @@ npm run test-unit ## Pull Requests - Pull requests should update `CHANGELOG.md` with a short description of the change. + +## Testing + +### Prefer end-to-end tests + +Most of this codebase is React components, and they are only reachable from an +end-to-end test. E2E coverage is the primary signal. + +Reach for a unit test only for pure logic that e2e cannot cheaply reach (parsers, +sorting, watchers, stores). Before writing one, check whether e2e already covers +the file — a unit test that duplicates existing e2e coverage adds test code and +almost no coverage: + +``` +npx nyc report --reporter=text --include="src/libs/style.ts" +``` + +Do **not** merge the Vitest (v8) and e2e (istanbul) coverage reports locally. They +produce conflicting statement maps for the same files and the combined percentage +is meaningless. Codecov merges the two uploads server-side; that is the number to +trust. Locally, read them separately: + +- e2e: `npx playwright test` then `npx nyc report --reporter=text-summary` (reads `.nyc_output/`) +- unit: `npx vitest run --coverage` (writes `coverage/`) + +### E2E layering + +Three layers, and the boundaries matter: + +- `e2e/playwright-helper.ts` — generic, app-agnostic browser actions. **The only + file allowed to import `@playwright/test`** (besides `e2e/utils/fixtures.ts`). +- `e2e/maputnik-driver.ts` — domain actions (layers, filters, functions, the + style). Knows nothing about `page` or Playwright. +- `e2e/modal-driver.ts` — actions scoped to a modal, exposed as `when.modal.*`. + +Specs get a driver at describe scope and assert fluently: + +```ts +describe("layer editor", () => { + const { given, get, when, then } = new MaputnikDriver(); + ... + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ layers: [{ id, type: "fill" }] }); +}); +``` + +New UI interactions belong in a driver, not inline in a spec. + +### Writing assertions + +- `shouldDeepNestedInclude` is a recursive partial match (`toMatchObject`): nested + objects are matched as subsets, arrays and primitives must match exactly + (including array length). +- Assert against the whole style, not an extracted slice. Avoid + `get.styleFromLocalStorage().then(style => style.layers.find(...))` — it moves + test logic into the test. Compare the real object instead. +- `Query.then()` is lazy and returns a new `Query`, **not** a Promise. `await + get.styleFromLocalStorage()` hangs forever. Use `.get()` to await it directly, + or pass the Query to `then(...)`. + +### One behaviour per test + +If a test needs comments narrating "and now this…", it is several tests. Split it, +and hoist the shared setup into a nested `describe` + `beforeEach`. + +### Test ids + +Test ids use the `data-wd-key` attribute and are read via `get.elementByTestId`. + +The `Input*` components already accept `data-wd-key` and render it on the real +``; the `Field*` wrappers forward it through their `{...props}` spread. So +passing `data-wd-key` to a `Field*` component is usually enough. Do **not** also +add it to `Block`/`Fieldset` — the id then matches two elements and locators fail +in strict mode. + +Note `InputNumber` renders `-text` and `-range` when `allowRange` is set, +and `` otherwise. + +### Input commit semantics (common source of "the value didn't save") + +- `InputString` only fires its `onChange` on **blur** or **Enter**. Typing alone + fires `onInput`. A driver that calls `fill()` must then call `blur()`, or the + value never reaches the style. +- `InputNumber` commits on every change; no blur needed. +- The autocomplete inputs (layer source, add-layer source) are controlled + downshift comboboxes. Keystroke typing is dropped/reordered — `{selectall}` then + typing `raster` yields `"exampleaster"`. Use `fill()`, which dispatches a single + input event, then pick from the filtered menu. +- CodeMirror auto-closes brackets and quotes, and types over its own closers, so + inserting a well-formed JSON fragment stays well-formed. To break JSON on + purpose, insert a bare word. + +### Fixtures + +Style fixtures live in `e2e/fixtures/`. A new one must be registered in two places +in `maputnik-driver.ts`: the list in `given.setupMockBackedResponses` and the +`styleFileByKey` map in `when.setStyle`. + +### Verify a new test can fail + +A test that passes for the wrong reason is worse than no test. After writing one, +mutate the expected value and confirm it fails. This has caught real mistakes +(e.g. a driver that never committed its input, so the assertion was matching a +value written by the *previous* step). diff --git a/e2e/access-tokens.spec.ts b/e2e/access-tokens.spec.ts new file mode 100644 index 00000000..101ddae3 --- /dev/null +++ b/e2e/access-tokens.spec.ts @@ -0,0 +1,60 @@ +import { test, expect, describe, beforeEach } from "./utils/fixtures"; +import { MaputnikDriver } from "./maputnik-driver"; +import tokens from "../src/config/tokens.json" with { type: "json" }; + +describe("access tokens", () => { + const { given, when } = new MaputnikDriver(); + + const tileJson = { + tilejson: "2.2.0", + tiles: ["https://example.local/{z}/{x}/{y}.pbf"], + minzoom: 0, + maxzoom: 14, + }; + + beforeEach(async () => { + await given.setupMockBackedResponses(); + }); + + test("uses the thunderforest token for a thunderforest source", async () => { + await given.interceptAndMockResponse({ + method: "GET", + url: /tile\.thunderforest\.com\/.*/, + response: tileJson, + alias: "thunderforest", + }); + + await when.setStyle("access_tokens"); + + const request = await when.waitForResponse("thunderforest"); + expect(request.url()).toContain(`apikey=${tokens.thunderforest}`); + }); + + test("uses the locationiq token for a locationiq source", async () => { + await given.interceptAndMockResponse({ + method: "GET", + url: /tiles\.locationiq\.com\/.*/, + response: tileJson, + alias: "locationiq", + }); + + await when.setStyle("access_tokens"); + + const request = await when.waitForResponse("locationiq"); + expect(request.url()).toContain(`key=${tokens.locationiq}`); + }); + + test("appends the stadia token as a query parameter", async () => { + await given.interceptAndMockResponse({ + method: "GET", + url: /tiles\.stadiamaps\.com\/.*/, + response: tileJson, + alias: "stadia", + }); + + await when.setStyle("access_tokens"); + + const request = await when.waitForResponse("stadia"); + expect(request.url()).toContain("?api_key=stadia-test-token"); + }); +}); diff --git a/e2e/fixtures/access-token-style.json b/e2e/fixtures/access-token-style.json new file mode 100644 index 00000000..2fb2cf2b --- /dev/null +++ b/e2e/fixtures/access-token-style.json @@ -0,0 +1,22 @@ +{ + "version": 8, + "name": "Access token style", + "metadata": { + "maputnik:stadia_access_token": "stadia-test-token" + }, + "sources": { + "thunderforest_transport": { + "type": "vector", + "url": "https://tile.thunderforest.com/thunderforest.transport-v2.json?apikey={key}" + }, + "stadia_outdoors": { + "type": "vector", + "url": "https://tiles.stadiamaps.com/data/openmaptiles.json" + }, + "locationiq": { + "type": "vector", + "url": "https://tiles.locationiq.com/v3/pbf/tiles.json?key={key}" + } + }, + "layers": [] +} diff --git a/e2e/layer-editor.spec.ts b/e2e/layer-editor.spec.ts index c510315b..4c2c49ce 100644 --- a/e2e/layer-editor.spec.ts +++ b/e2e/layer-editor.spec.ts @@ -25,7 +25,18 @@ describe("layer editor", () => { return id; } - test.skip("expand/collapse", () => {}); + test("expand/collapse", async () => { + const bgId = await createBackground(); + await when.click("layer-list-item:background:" + bgId); + + await then(get.elementByTestId("layer-editor.layer-id.input")).shouldBeVisible(); + + await when.toggleGroupInLayerEditor("Layer"); + await then(get.elementByTestId("layer-editor.layer-id.input")).shouldNotBeVisible(); + + await when.toggleGroupInLayerEditor("Layer"); + await then(get.elementByTestId("layer-editor.layer-id.input")).shouldBeVisible(); + }); test("id", async () => { const bgId = await createBackground(); @@ -76,6 +87,14 @@ 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()).shouldDeepNestedInclude({ + layers: [{ id: "background:" + bgId, type: "background", minzoom: 2 }], + }); + }); }); describe("max-zoom", () => { @@ -145,6 +164,13 @@ 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", () => { @@ -166,8 +192,287 @@ describe("layer editor", () => { }); describe("filter", () => { - test.skip("expand/collapse", () => {}); - test.skip("compound filter", () => {}); + let id: string; + + beforeEach(async () => { + id = await when.modal.fillLayers({ type: "fill", layer: "example" }); + await when.addFilter(); + }); + + test("should add a filter item", async () => { + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id, type: "fill", source: "example", filter: ["all", ["==", "name", ""]] }], + }); + }); + + test("should change the filter operator", async () => { + await when.selectFilterOperator("!="); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id, filter: ["all", ["!=", "name", ""]] }], + }); + }); + + test("should extend the compound filter with a second item", async () => { + await when.addFilter(); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id, filter: ["all", ["==", "name", ""], ["==", "name", ""]] }], + }); + }); + + test("should change the combining operator", async () => { + await when.selectFilterCombiningOperator("any"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id, filter: ["any", ["==", "name", ""]] }], + }); + }); + + test("should delete a filter item", async () => { + await when.addFilter(); + await when.deleteFilterItem(); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id, filter: ["all", ["==", "name", ""]] }], + }); + }); + + describe("when converted to an expression", () => { + beforeEach(async () => { + await when.convertFilterToExpression(); + }); + + test("should migrate the filter to an expression", async () => { + // A single-item "all" collapses to the bare comparison when migrated. + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id, filter: ["==", ["get", "name"], ""] }], + }); + }); + + test("should restore the default filter when the expression is deleted", async () => { + await when.deleteFilterExpression(); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id, filter: ["all"] }], + }); + }); + }); + }); + + describe("functions", () => { + let id: string; + + describe("zoom function", () => { + beforeEach(async () => { + id = await when.modal.fillLayers({ type: "circle", layer: "example" }); + await when.makeZoomFunction("circle-radius"); + }); + + test("should convert the property to a zoom function", async () => { + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [ + { id, type: "circle", source: "example", paint: { "circle-radius": { stops: [[6, 5], [10, 5]] } } }, + ], + }); + }); + + test("should add a stop", async () => { + await when.addFunctionStop("circle-radius"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id, paint: { "circle-radius": { stops: [[6, 5], [10, 5], [11, 5]] } } }], + }); + }); + + test("should delete the first stop", async () => { + // A function needs more than two stops, otherwise deleting one collapses + // it back into a plain value. + await when.addFunctionStop("circle-radius"); + await when.deleteFunctionStop("circle-radius"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id, paint: { "circle-radius": { stops: [[10, 5], [11, 5]] } } }], + }); + }); + + test("should set the base", async () => { + await when.setFunctionBase("circle-radius", "2"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id, paint: { "circle-radius": { base: 2, stops: [[6, 5], [10, 5]] } } }], + }); + }); + + test("should edit the zoom of a stop", async () => { + await when.setFunctionStopValue("circle-radius", "Zoom", 0, "3"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id, paint: { "circle-radius": { stops: [[3, 5], [10, 5]] } } }], + }); + }); + + test("should edit the output value of a stop", async () => { + await when.setFunctionStopValue("circle-radius", "Output value", 0, "9"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id, paint: { "circle-radius": { stops: [[6, 9], [10, 5]] } } }], + }); + }); + + test("should convert to an expression", async () => { + await when.makeExpression("circle-radius"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id, paint: { "circle-radius": ["interpolate", ["linear"], ["zoom"], 6, 5, 10, 5] } }], + }); + }); + + describe("when converted to a data function", () => { + beforeEach(async () => { + // Any non-interpolate scale turns the zoom function into a data one. + await when.selectFunctionType("circle-radius", "categorical"); + }); + + test("should carry the stops over as zoom/value pairs", async () => { + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [ + { + id, + paint: { + "circle-radius": { + property: "", + type: "exponential", + stops: [[{ zoom: 6, value: 0 }, 5], [{ zoom: 10, value: 0 }, 5]], + }, + }, + }, + ], + }); + }); + + test("should convert back to a zoom function", async () => { + await when.selectFunctionType("circle-radius", "interpolate"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id, paint: { "circle-radius": { stops: [[6, 5], [10, 5]] } } }], + }); + }); + }); + }); + + describe("data function", () => { + beforeEach(async () => { + id = await when.modal.fillLayers({ type: "circle", layer: "example" }); + await when.setValue("spec-field-input:circle-blur", "1"); + await when.makeDataFunction("circle-blur"); + }); + + test("should convert the property to a data function", async () => { + 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]], + }, + }, + }, + ], + }); + }); + + test("should add a stop", async () => { + await when.addFunctionStop("circle-blur"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [ + { + id, + paint: { + "circle-blur": { + stops: [[{ zoom: 6, value: 0 }, 1], [{ zoom: 10, value: 0 }, 1], [{ zoom: 11, value: 0 }, 1]], + }, + }, + }, + ], + }); + }); + + test("should delete the first stop", async () => { + await when.addFunctionStop("circle-blur"); + await when.deleteFunctionStop("circle-blur"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [ + { + id, + paint: { + "circle-blur": { + stops: [[{ zoom: 10, value: 0 }, 1], [{ zoom: 11, value: 0 }, 1]], + }, + }, + }, + ], + }); + }); + + test("should set the property", async () => { + await when.setFunctionProperty("circle-blur", "myprop"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id, paint: { "circle-blur": { property: "myprop" } } }], + }); + }); + + test("should set the default", async () => { + await when.setFunctionDefault("circle-blur", "0.5"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id, paint: { "circle-blur": { default: 0.5 } } }], + }); + }); + + test("should edit the input value of a stop", async () => { + await when.setFunctionStopValue("circle-blur", "Input value", 0, "7"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [ + { + id, + paint: { + "circle-blur": { + stops: [[{ zoom: 6, value: 7 }, 1], [{ zoom: 10, value: 0 }, 1]], + }, + }, + }, + ], + }); + }); + + test("should change the function type", async () => { + await when.selectFunctionType("circle-blur", "categorical"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id, paint: { "circle-blur": { type: "categorical" } } }], + }); + }); + }); + + describe("expression", () => { + beforeEach(async () => { + id = await when.modal.fillLayers({ type: "circle", layer: "example" }); + await when.setValue("spec-field-input:circle-blur", "1"); + await when.makeExpression("circle-blur"); + }); + + test("should wrap the property value in a literal expression", async () => { + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id, paint: { "circle-blur": ["literal", 1] } }], + }); + }); + + test("should restore the plain value when reverted", async () => { + await when.undoExpression("circle-blur"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id, paint: { "circle-blur": 1 } }], + }); + }); + + test("should fall back to the spec default when deleted", async () => { + await when.deleteExpression("circle-blur"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id, paint: { "circle-blur": 0 } }], + }); + }); + }); }); describe("layout", () => { @@ -183,10 +488,43 @@ describe("layer editor", () => { }); describe("paint", () => { - test.skip("expand/collapse", () => {}); - test.skip("color", () => {}); - test.skip("pattern", () => {}); - test.skip("opacity", () => {}); + let id: string; + + beforeEach(async () => { + id = await when.modal.fillLayers({ type: "fill", layer: "example" }); + }); + + test("expand/collapse", async () => { + await then(get.elementByTestId("spec-field:fill-color")).shouldBeVisible(); + + await when.toggleGroupInLayerEditor("Paint properties"); + await then(get.elementByTestId("spec-field:fill-color")).shouldNotBeVisible(); + + await when.toggleGroupInLayerEditor("Paint properties"); + await then(get.elementByTestId("spec-field:fill-color")).shouldBeVisible(); + }); + + test("color", async () => { + await when.setColorValue("fill-color", "#ff0000"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id, type: "fill", source: "example", paint: { "fill-color": "#ff0000" } }], + }); + }); + + test("pattern", async () => { + await when.setStringValue("fill-pattern", "some-pattern"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id, type: "fill", source: "example", paint: { "fill-pattern": "some-pattern" } }], + }); + }); + + test("opacity", async () => { + await when.setValue("spec-field-input:fill-opacity", "0.4"); + await when.click("layer-editor.layer-id"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id, type: "fill", source: "example", paint: { "fill-opacity": 0.4 } }], + }); + }); }); describe("json-editor", () => { @@ -206,8 +544,28 @@ describe("layer editor", () => { await then(get.element(".cm-lint-marker-error")).shouldExist(); }); - test.skip("expand/collapse", () => {}); - test.skip("modify", () => {}); + test("expand/collapse", async () => { + const bgId = await createBackground(); + await when.click("layer-list-item:background:" + bgId); + + await then(get.element(".cm-content")).shouldBeVisible(); + + await when.toggleGroupInLayerEditor("JSON Editor"); + await then(get.element(".cm-content")).shouldNotBeVisible(); + + await when.toggleGroupInLayerEditor("JSON Editor"); + await then(get.element(".cm-content")).shouldBeVisible(); + }); + + test("modify", async () => { + const bgId = await createBackground(); + await when.click("layer-list-item:background:" + bgId); + + await when.appendToJsonEditorLine('"background"', ',\n"minzoom": 5'); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id: "background:" + bgId, type: "background", minzoom: 5 }], + }); + }); test("parse error", async () => { const bgId = await createBackground(); diff --git a/e2e/layers-list.spec.ts b/e2e/layers-list.spec.ts index 786a1b3d..56c3529b 100644 --- a/e2e/layers-list.spec.ts +++ b/e2e/layers-list.spec.ts @@ -97,7 +97,15 @@ describe("layers list", () => { }); }); - test.skip("modify", () => {}); + test("modify", async () => { + const id = await when.modal.fillLayers({ type: "background" }); + await when.click("layer-list-item:" + id); + await when.setValue("spec-field-input:background-opacity", "0.4"); + await when.click("layer-editor.layer-id"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id, type: "background", paint: { "background-opacity": 0.4 } }], + }); + }); }); describe("fill", () => { @@ -108,8 +116,13 @@ describe("layers list", () => { }); }); - // TODO: Change source - test.skip("change source", () => {}); + test("change source", async () => { + const id = await when.modal.fillLayers({ type: "fill", layer: "example" }); + await when.changeLayerSource("raster"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id, type: "fill", source: "raster" }], + }); + }); }); describe("line", () => { diff --git a/e2e/maputnik-driver.ts b/e2e/maputnik-driver.ts index ad594a9e..9851a542 100644 --- a/e2e/maputnik-driver.ts +++ b/e2e/maputnik-driver.ts @@ -38,6 +38,7 @@ export class MaputnikDriver { "example-style-with-fonts.json", "example-style-with-zoom-7-and-center-0-51.json", "example-style-with-zoom-5-and-center-50-50.json", + "access-token-style.json", ]; for (const fixture of styleFixtures) { await this.helper.given.interceptAndMockResponse({ @@ -71,6 +72,7 @@ export class MaputnikDriver { | "rectangles" | "font" | "zoom_7_center_0_51" + | "access_tokens" | "", zoom?: number ) => { @@ -82,6 +84,7 @@ export class MaputnikDriver { rectangles: "rectangles-style.json", font: "example-style-with-fonts.json", zoom_7_center_0_51: "example-style-with-zoom-7-and-center-0-51.json", + access_tokens: "access-token-style.json", }; const url = new URL(baseUrl); @@ -107,7 +110,13 @@ export class MaputnikDriver { }, chooseExampleFile: async () => { - await this.helper.when.openFileByFixture("example-style.json", "modal:open.dropzone", "modal:open.file.input"); + await this.helper.when.openFileByFixture("example-style.json", "modal:open.dropzone"); + await this.helper.when.wait(200); + }, + + /** Picks the example style through the browser's native file chooser. */ + chooseExampleFileFromPicker: async () => { + await this.helper.when.chooseFileFromPicker("example-style.json", "modal:open.dropzone"); await this.helper.when.wait(200); }, @@ -128,6 +137,22 @@ export class MaputnikDriver { await this.helper.get.element(".maputnik-layer-editor-group__button").nth(index).click(); }, + /** Expands/collapses a layer-editor group by its title, e.g. "Paint properties". */ + toggleGroupInLayerEditor: async (title: string) => { + await this.helper.when.click("layer-editor-group:" + title); + }, + + /** + * Picks a source for the selected layer from the source autocomplete. + * The autocomplete is a controlled (downshift) input, so the value has to be + * filled rather than typed key by key, then chosen from the filtered menu. + */ + changeLayerSource: async (sourceId: string) => { + const input = this.helper.get.elementByTestId("layer-editor.layer-source").locator("input"); + await input.fill(sourceId); + await this.helper.get.element(".maputnik-autocomplete-menu-item").first().click(); + }, + appendTextInJsonEditor: async (text: string) => { await this.helper.get.element(".cm-line").first().click(); // Move to the very start of the document so the inserted text breaks the @@ -158,6 +183,147 @@ 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 }); + }, + + /** Turns the property into a raw style expression. */ + makeExpression: async (fieldName: string) => { + const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName); + await container.scrollIntoViewIfNeeded(); + // In the plain spec field the expression button shares the zoom-function + // class and comes first; inside a function editor it has its own test id. + const inFunctionEditor = container.locator("[data-wd-key='convert-to-expression']"); + const button = + (await inFunctionEditor.count()) > 0 + ? inFunctionEditor + : container.locator(".maputnik-make-zoom-function").first(); + await button.click({ force: true }); + }, + + /** Reverts an expression back to a plain value. */ + undoExpression: async (fieldName: string) => { + const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName); + await container.locator("[data-wd-key='undo-expression']").click({ force: true }); + }, + + /** Removes an expression, restoring the property's spec default. */ + deleteExpression: async (fieldName: string) => { + const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName); + await container.locator("[data-wd-key='delete-expression']").click({ force: true }); + }, + + /** Picks the function scale (categorical/interval/exponential/identity/interpolate). */ + selectFunctionType: async (fieldName: string, type: string) => { + const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName); + await container.locator("[data-wd-key='function-type'] select").selectOption(type); + }, + + /** Sets the "Base" input of a zoom/data function. */ + setFunctionBase: async (fieldName: string, value: string) => { + const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName); + await container.locator("[data-wd-key='function-base'] input").fill(value); + }, + + /** + * Sets the data property a data function keys off of. This is an InputString, + * which only commits its value on blur, so typing alone is not enough. + */ + setFunctionProperty: async (fieldName: string, value: string) => { + const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName); + const input = container.locator("[data-wd-key='function-property'] input"); + await input.fill(value); + await input.blur(); + }, + + /** Sets the fallback value used when a feature has no matching stop. */ + setFunctionDefault: async (fieldName: string, value: string) => { + const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName); + await container.locator("[data-wd-key='function-default'] input").fill(value); + }, + + /** Edits one cell of a function's stop table ("Zoom", "Input value" or "Output value"). */ + setFunctionStopValue: async (fieldName: string, column: string, index: number, value: string) => { + const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName); + await container.locator(`[aria-label="${column}"]`).nth(index).fill(value); + }, + + 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); + }, + + /** Chooses how the filter items combine: all / none / any. */ + selectFilterCombiningOperator: async (value: string) => { + await this.helper.when.selectWithin("filter-combining-operator", value); + }, + + deleteFilterItem: async (index = 0) => { + await this.helper.get + .element(".maputnik-filter-editor-block-action .maputnik-icon-button") + .nth(index) + .click(); + }, + + /** Converts the simple filter editor into a raw expression editor. */ + convertFilterToExpression: async () => { + await this.helper.when.click("filter-convert-to-expression"); + }, + + /** + * Deletes the filter expression, restoring the simple filter editor. + * The filter group precedes the paint group, so its button comes first. + */ + deleteFilterExpression: async () => { + await this.helper.get.element("[data-wd-key='delete-expression']").first().click(); + }, + + setColorValue: async (fieldName: string, value: string) => { + const input = this.helper.get.elementByTestId("spec-field:" + fieldName).locator(".maputnik-color"); + await input.fill(value); + }, + + /** Sets a plain string spec field (e.g. a pattern), which has no dedicated input test id. */ + setStringValue: async (fieldName: string, value: string) => { + const input = this.helper.get.elementByTestId("spec-field:" + fieldName).locator("input.maputnik-string"); + await input.fill(value); + await input.blur(); + }, + + /** + * Appends text to the end of the JSON editor line holding `lineText`. + * CodeMirror types over its own auto-inserted closing quotes/brackets, so a + * well-formed fragment stays well-formed. + */ + appendToJsonEditorLine: async (lineText: string, text: string) => { + await this.helper.when.clickByText(lineText); + await this.helper.when.typeKeys("{end}"); + await this.helper.when.typeText(text); + }, + waitForExampleFileResponse: () => this.helper.when.waitForResponse("example-style.json"), /** Fill localStorage until we get a QuotaExceededError. */ diff --git a/e2e/modal-driver.ts b/e2e/modal-driver.ts index 3f7e4915..015ce9e5 100644 --- a/e2e/modal-driver.ts +++ b/e2e/modal-driver.ts @@ -34,5 +34,45 @@ export class ModalDriver { close: async (key: string) => { await this.helper.when.click(key + ".close-modal"); }, + + /** + * Adds a source of the given type from the sources modal, keeping whatever + * defaults that type's editor prefills. + */ + addSource: async (sourceId: string, sourceType: string) => { + const { when } = this.helper; + await when.setValue("modal:sources.add.source_id", sourceId); + await when.select("modal:sources.add.source_type", sourceType); + await when.click("modal:sources.add.add_source"); + await when.wait(200); + }, + + /** Adds one of the predefined public sources listed in the sources modal. */ + addPublicSource: async (index = 0) => { + await this.helper.get.element(".maputnik-public-source-select").nth(index).click(); + }, + + deleteFirstActiveSource: async () => { + await this.helper.get.element(".maputnik-active-source-type-editor-header-delete").first().click(); + }, + + /** Fills one number box of a coordinate pair in the image/video source editor. */ + setCoordinateValue: async (index: number, value: string) => { + const input = this.helper.get + .elementByTestId("modal:sources") + .locator(".maputnik-array input") + .nth(index); + await input.fill(value); + await input.blur(); + }, + + 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(); + }, }; } diff --git a/e2e/modals.spec.ts b/e2e/modals.spec.ts index 370fb556..e2d62dd7 100644 --- a/e2e/modals.spec.ts +++ b/e2e/modals.spec.ts @@ -43,6 +43,16 @@ describe("modals", () => { await then(get.styleFromLocalStorage()).shouldDeepNestedInclude(get.responseBody("example-style.json")); }); }); + + describe("without the File System Access API", () => { + test("upload via the file chooser", async () => { + await given.noFileSystemAccessApi(); + await when.setStyle(""); + await when.click("nav:open"); + await when.chooseExampleFileFromPicker(); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude(get.fixture("example-style.json")); + }); + }); }); describe("shortcuts", () => { @@ -64,8 +74,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.modal.exportCreateHtml(); + await then(get.elementByTestId("modal:export")).shouldExist(); + + // Saving the style closes the export modal. + await when.modal.exportSaveStyle(); + await then(get.elementByTestId("modal:export")).shouldNotExist(); + }); }); describe("sources", () => { @@ -74,8 +91,27 @@ describe("modals", () => { await when.click("nav:sources"); }); - test.skip("active sources", () => {}); - test.skip("public source", () => {}); + test("active sources are listed and can be deleted", async () => { + await when.setStyle("both"); + await when.click("nav:sources"); + const before = Object.keys(get.fixture("geojson-raster-style.json").sources).length; + await when.modal.deleteFirstActiveSource(); + await then( + get.styleFromLocalStorage().then((style) => Object.keys(style.sources).length) + ).shouldEqual(before - 1); + }); + + test("public source", async () => { + await when.modal.addPublicSource(); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + sources: { + openmaptiles: { + type: "vector", + url: `https://api.maptiler.com/tiles/v3-openmaptiles/tiles.json?key=${tokens.openmaptiles}`, + }, + }, + }); + }); test("add new source", async () => { const sourceId = "n1z2v3r"; @@ -84,8 +120,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().then((style) => style.sources[sourceId])).shouldInclude({ - scheme: "tms", + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + sources: { [sourceId]: { scheme: "tms" } }, }); }); @@ -118,8 +154,110 @@ 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().then((style) => style.sources[sourceId])).shouldInclude({ - tileSize: 128, + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + sources: { [sourceId]: { tileSize: 128 } }, + }); + }); + + test("add new geojson url source", async () => { + await when.modal.addSource("geojsonurl", "geojson_url"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + sources: { + geojsonurl: { type: "geojson", data: "http://localhost:3000/geojson.json" }, + }, + }); + }); + + test("add new geojson json source", async () => { + await when.modal.addSource("geojsonjson", "geojson_json"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + sources: { + geojsonjson: { type: "geojson", cluster: false, data: "" }, + }, + }); + }); + + test("add new tilejson vector source", async () => { + await when.modal.addSource("tilejsonvector", "tilejson_vector"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + sources: { + tilejsonvector: { type: "vector", url: "http://localhost:3000/tilejson.json" }, + }, + }); + }); + + test("add new tilejson raster source", async () => { + await when.modal.addSource("tilejsonraster", "tilejson_raster"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + sources: { + tilejsonraster: { type: "raster", url: "http://localhost:3000/tilejson.json" }, + }, + }); + }); + + test("add new tilejson raster-dem source", async () => { + await when.modal.addSource("tilejsonrasterdem", "tilejson_raster-dem"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + sources: { + tilejsonrasterdem: { type: "raster-dem", url: "http://localhost:3000/tilejson.json" }, + }, + }); + }); + + test("add new tile xyz raster-dem source", async () => { + await when.modal.addSource("tilexyzrasterdem", "tilexyz_raster-dem"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + sources: { + tilexyzrasterdem: { + type: "raster-dem", + tiles: ["http://localhost:3000/{x}/{y}/{z}.png"], + minzoom: 0, + maxzoom: 14, + tileSize: 512, + }, + }, + }); + }); + + test("add new image source", async () => { + await when.modal.addSource("imagesource", "image"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + sources: { + imagesource: { + type: "image", + url: "http://localhost:3000/image.png", + coordinates: [[0, 0], [0, 0], [0, 0], [0, 0]], + }, + }, + }); + }); + + test("add new video source", async () => { + await when.modal.addSource("videosource", "video"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + sources: { + videosource: { + type: "video", + urls: ["http://localhost:3000/movie.mp4"], + coordinates: [[0, 0], [0, 0], [0, 0], [0, 0]], + }, + }, + }); + }); + + test("edit the corner coordinates of an image source", async () => { + const sourceId = "imagecoords"; + await when.setValue("modal:sources.add.source_id", sourceId); + await when.select("modal:sources.add.source_type", "image"); + // The first corner is the first two number boxes of the coordinate arrays. + await when.modal.setCoordinateValue(0, "1"); + await when.modal.setCoordinateValue(1, "2"); + await when.click("modal:sources.add.add_source"); + await when.wait(200); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + sources: { + [sourceId]: { type: "image", coordinates: [[1, 2], [0, 0], [0, 0], [0, 0]] }, + }, }); }); }); @@ -204,8 +342,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().then((style) => style.metadata)).shouldInclude({ - "maputnik:openmaptiles_access_token": apiKey, + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + metadata: { "maputnik:openmaptiles_access_token": apiKey }, }); }); @@ -213,8 +351,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().then((style) => style.metadata)).shouldInclude({ - "maputnik:thunderforest_access_token": apiKey, + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + metadata: { "maputnik:thunderforest_access_token": apiKey }, }); }); @@ -222,8 +360,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().then((style) => style.metadata)).shouldInclude({ - "maputnik:stadia_access_token": apiKey, + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + metadata: { "maputnik:stadia_access_token": apiKey }, }); }); @@ -231,29 +369,67 @@ 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().then((style) => style.metadata)).shouldInclude({ - "maputnik:locationiq_access_token": apiKey, + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + metadata: { "maputnik:locationiq_access_token": apiKey }, + }); + }); + + test("map view defaults", async () => { + await when.setValue("modal:settings.zoom", "4"); + await when.setValue("modal:settings.bearing", "12"); + await when.setValue("modal:settings.pitch", "30"); + await when.click("modal:settings.name"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + zoom: 4, + bearing: 12, + pitch: 30, + }); + }); + + test("light intensity", async () => { + await when.setValue("modal:settings.light-intensity", "0.7"); + await when.click("modal:settings.name"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + light: { intensity: 0.7 }, + }); + }); + + test("terrain source and exaggeration", async () => { + await when.setValue("modal:settings.maputnik:terrain_source", "terrain"); + await when.setValue("modal:settings.terrain-exaggeration", "1.5"); + await when.click("modal:settings.name"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + terrain: { source: "terrain", exaggeration: 1.5 }, + }); + }); + + test("transition delay and duration", async () => { + await when.setValue("modal:settings.transition-delay", "100"); + await when.setValue("modal:settings.transition-duration", "500"); + await when.click("modal:settings.name"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + transition: { delay: 100, duration: 500 }, }); }); test("style projection mercator", async () => { await when.select("modal:settings.projection", "mercator"); - await then(get.styleFromLocalStorage().then((style) => style.projection)).shouldInclude({ - type: "mercator", + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + projection: { type: "mercator" }, }); }); test("style projection globe", async () => { await when.select("modal:settings.projection", "globe"); - await then(get.styleFromLocalStorage().then((style) => style.projection)).shouldInclude({ - type: "globe", + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + projection: { type: "globe" }, }); }); test("style projection vertical-perspective", async () => { await when.select("modal:settings.projection", "vertical-perspective"); - await then(get.styleFromLocalStorage().then((style) => style.projection)).shouldInclude({ - type: "vertical-perspective", + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + projection: { type: "vertical-perspective" }, }); }); @@ -311,10 +487,6 @@ describe("modals", () => { }); }); - describe("sources placeholder", () => { - test.skip("toggle", () => {}); - }); - describe("global state", () => { beforeEach(async () => { await when.click("nav:global-state"); diff --git a/e2e/playwright-helper.ts b/e2e/playwright-helper.ts index b99a2f02..80f23f84 100644 --- a/e2e/playwright-helper.ts +++ b/e2e/playwright-helper.ts @@ -62,11 +62,12 @@ 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 { - for (const key of Object.keys(expected)) { - expect(actual?.[key], `property "${key}"`).toEqual(expected[key]); - } +/** + * 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 | unknown[]): void { + expect(actual).toMatchObject(expected); } /** @@ -130,7 +131,7 @@ export class Assertable { } }); - shouldDeepNestedInclude = (value: Record) => + shouldDeepNestedInclude = (value: Record | unknown[]) => this.assertValue((actual) => assertDeepNestedInclude(actual, value)); } @@ -144,6 +145,8 @@ async function typeSequence(page: Page, text: string): Promise { del: "Delete", tab: "Tab", home: "Home", + end: "End", + rightarrow: "ArrowRight", }; for (let i = 0; i < tokens.length; i++) { @@ -202,10 +205,31 @@ 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); public given = { + /** + * Removes the File System Access API so the app falls back to a plain + * , the way Firefox and Safari behave. Must be called + * before the page under test is loaded. + */ + noFileSystemAccessApi: async () => { + await this.page.addInitScript(() => { + delete (window as any).showOpenFilePicker; + delete (window as any).showSaveFilePicker; + }); + }, + intercept: async (pattern: RegExp, alias: string, _method = "GET") => { this.recordedRequests.set(alias, []); await this.page.route(pattern, (route) => { @@ -333,23 +357,38 @@ export class PlaywrightHelper { await this.page.mouse.up(); }, - openFileByFixture: async (fixture: string, buttonTestId: string, inputTestId: string) => { + /** + * Opens a fixture through the File System Access API, which raises no + * "filechooser" event and so has to be stubbed. For the + * fallback that browsers without the API use, see chooseFileFromPicker. + */ + openFileByFixture: async (fixture: string, buttonTestId: string) => { const content = JSON.stringify(this.readFixture(fixture)); - const hasPicker = await this.page.evaluate(() => "showOpenFilePicker" in window); - if (hasPicker) { - await this.page.evaluate((fileContent) => { - (window as any).showOpenFilePicker = async () => [ - { getFile: async () => ({ text: async () => fileContent }) }, - ]; - }, content); - await this.testId(buttonTestId).click(); - } else { - await this.testId(inputTestId).setInputFiles({ - name: fixture, - mimeType: "application/json", - buffer: Buffer.from(content), - }); - } + await this.page.evaluate((fileContent) => { + (window as any).showOpenFilePicker = async () => [ + { getFile: async () => ({ text: async () => fileContent }) }, + ]; + }, content); + await this.testId(buttonTestId).click(); + }, + + /** + * Clicks a control that opens the browser's native file chooser and answers + * it with a fixture. Only works on the path — the File + * System Access API does not raise a "filechooser" event, so pair this with + * given.noFileSystemAccessApi(). + */ + chooseFileFromPicker: async (fixture: string, triggerTestId: string) => { + const content = JSON.stringify(this.readFixture(fixture)); + const [fileChooser] = await Promise.all([ + this.page.waitForEvent("filechooser"), + this.testId(triggerTestId).click(), + ]); + await fileChooser.setFiles({ + name: fixture, + mimeType: "application/json", + buffer: Buffer.from(content), + }); }, dropFileByFixture: async (fixture: string, dropzoneTestId: string) => { diff --git a/package-lock.json b/package-lock.json index f55f9a3f..1ace8290 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", @@ -2631,13 +2621,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", @@ -4066,53 +4049,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/coverage-v8": { "version": "4.1.10", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", @@ -8387,9 +8323,9 @@ } }, "node_modules/istanbul-lib-processinfo": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-processinfo/-/istanbul-lib-processinfo-3.0.1.tgz", - "integrity": "sha512-s3mX05h5wGZeScG6XnOanygPh4SJu5ujMc9YbvpnLGXWy1cRiGbp0NdVcjHxgoZt3WfQppfBsa0y+gWdYJ2pGQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-processinfo/-/istanbul-lib-processinfo-3.0.0.tgz", + "integrity": "sha512-P7nLXRRlo7Sqinty6lNa7+4o9jBUYGpqtejqCOZKfgXlRoxY/QArflcB86YO500Ahj4pDJEG34JjMRbQgePLnQ==", "dev": true, "license": "ISC", "dependencies": { @@ -8397,7 +8333,8 @@ "cross-spawn": "^7.0.3", "istanbul-lib-coverage": "^3.2.0", "p-map": "^3.0.0", - "rimraf": "^6.1.3" + "rimraf": "^6.1.3", + "uuid": "^8.3.2" }, "engines": { "node": "20 || >=22" @@ -8416,6 +8353,16 @@ "node": ">=8" } }, + "node_modules/istanbul-lib-processinfo/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/istanbul-lib-report": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", @@ -9756,16 +9703,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", @@ -10807,16 +10744,6 @@ "fflate": "^0.8.2" } }, - "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/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -11064,13 +10991,12 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.15.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", - "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", "license": "BSD-3-Clause", "dependencies": { - "es-define-property": "^1.0.1", - "side-channel": "^1.1.1" + "side-channel": "^1.1.0" }, "engines": { "node": ">=0.6" @@ -11938,14 +11864,14 @@ } }, "node_modules/side-channel": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", - "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.4", - "side-channel-list": "^1.0.1", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -11957,13 +11883,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", - "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.4" + "object-inspect": "^1.13.3" }, "engines": { "node": ">= 0.4" @@ -12023,21 +11949,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", @@ -12909,16 +12820,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", @@ -13669,31 +13570,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", @@ -13881,28 +13757,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 770e4919..202e27b5 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/DataProperty.tsx b/src/components/DataProperty.tsx index 1a3a7d4d..2a839150 100644 --- a/src/components/DataProperty.tsx +++ b/src/components/DataProperty.tsx @@ -289,6 +289,7 @@ class DataPropertyInternal extends React.Component
diff --git a/src/components/ExpressionProperty.tsx b/src/components/ExpressionProperty.tsx index 0394fd57..2c6d86bc 100644 --- a/src/components/ExpressionProperty.tsx +++ b/src/components/ExpressionProperty.tsx @@ -50,6 +50,7 @@ class ExpressionPropertyInternal extends React.Component @@ -59,6 +60,7 @@ class ExpressionPropertyInternal extends React.Component diff --git a/src/components/FilterEditor.tsx b/src/components/FilterEditor.tsx index fb4b5d29..f328e688 100644 --- a/src/components/FilterEditor.tsx +++ b/src/components/FilterEditor.tsx @@ -215,6 +215,7 @@ class FilterEditorInternal extends React.Component @@ -248,6 +249,7 @@ class FilterEditorInternal extends React.Component { - 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/components/LayerEditor.tsx b/src/components/LayerEditor.tsx index b87ed2be..7cbd09c9 100644 --- a/src/components/LayerEditor.tsx +++ b/src/components/LayerEditor.tsx @@ -230,6 +230,7 @@ class LayerEditorInternal extends React.Component {this.props.layer.type !== "background" &&
diff --git a/src/components/modals/ModalSettings.tsx b/src/components/modals/ModalSettings.tsx index 9dbcc685..f718ed29 100644 --- a/src/components/modals/ModalSettings.tsx +++ b/src/components/modals/ModalSettings.tsx @@ -206,6 +206,7 @@ class ModalSettingsInternal extends React.Component ): 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..4806b98f --- /dev/null +++ b/src/libs/layerwatcher.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, vi } from "vitest"; +import { LayerWatcher } from "./layerwatcher"; +import type { Map } from "maplibre-gl"; + +function mockMap(): Map { + return { + style: { + tileManagers: { + vector: { _source: { vectorLayerIds: ["water", "roads"] } }, + }, + }, + querySourceFeatures: () => [ + { properties: { class: "river", name: "A" } }, + { properties: { class: "canal" } }, + ], + } as unknown as Map; +} + +describe("LayerWatcher", () => { + it("reports sources discovered on the map", () => { + const onSourcesChange = vi.fn(); + const watcher = new LayerWatcher({ onSourcesChange }); + + watcher.analyzeMap(mockMap()); + expect(onSourcesChange).toHaveBeenCalledOnce(); + expect(watcher.sources).toEqual({ vector: ["water", "roads"] }); + + // Re-analyzing the same sources does not fire the callback again. + watcher.analyzeMap(mockMap()); + expect(onSourcesChange).toHaveBeenCalledOnce(); + }); + + it("collects vector layer field values from source features", () => { + const onVectorLayersChange = vi.fn(); + const watcher = new LayerWatcher({ onVectorLayersChange }); + + const map = mockMap(); + watcher.analyzeMap(map); // populates _sources + watcher.analyzeVectorLayerFields(map); + + expect(onVectorLayersChange).toHaveBeenCalled(); + expect(watcher.vectorLayers.water.class).toHaveProperty("river"); + expect(watcher.vectorLayers.water.class).toHaveProperty("canal"); + expect(watcher.vectorLayers.water.name).toHaveProperty("A"); + }); +}); diff --git a/src/libs/sort-numerically.test.ts b/src/libs/sort-numerically.test.ts new file mode 100644 index 00000000..680fa6e5 --- /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/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..f2b718ab --- /dev/null +++ b/src/libs/store/stylestore.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { StyleStore } from "./stylestore"; + +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() }); +// loadDefaultStyle fetches the default style over the network; serve it locally. +vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + json: async () => ({ version: 8, id: "default", sources: {}, layers: [] }), + })) +); + +const style = (id: string) => ({ version: 8, id, sources: {}, layers: [] }) as any; + +describe("StyleStore", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it("returns the default style when nothing is stored", async () => { + const store = new StyleStore(); + const latest = await store.getLatestStyle(); + expect(latest.id).toBe("default"); + }); + + it("saves a style and reads it back as the latest", async () => { + const store = new StyleStore(); + store.save(style("abc")); + // A fresh store discovers the persisted style ids. + const reopened = new StyleStore(); + const latest = await reopened.getLatestStyle(); + expect(latest.id).toBe("abc"); + }); + + it("purge removes all maputnik keys", async () => { + const store = new StyleStore(); + store.save(style("abc")); + window.localStorage.setItem("unrelated", "keep"); + store.purge(); + expect(window.localStorage.getItem("maputnik:style:abc")).toBeNull(); + expect(window.localStorage.getItem("unrelated")).toBe("keep"); + }); +}); diff --git a/src/libs/style.ts b/src/libs/style.ts index 47a1b742..0acd20f9 100644 --- a/src/libs/style.ts +++ b/src/libs/style.ts @@ -45,15 +45,6 @@ function ensureStyleValidity(style: StyleSpecification): StyleSpecificationWithI return ensureHasNoInteractive(ensureHasNoRefs(ensureHasId(style))); } -function indexOfLayer(layers: LayerSpecification[], layerId: string) { - for (let i = 0; i < layers.length; i++) { - if(layers[i].id === layerId) { - return i; - } - } - return null; -} - function getAccessToken(sourceName: string, mapStyle: StyleSpecification, opts: {allowFallback?: boolean}) { const metadata = mapStyle.metadata || {} as any; let accessToken = metadata[`maputnik:${sourceName}_access_token`]; @@ -151,7 +142,6 @@ function stripAccessTokens(mapStyle: StyleSpecification) { export { ensureStyleValidity, emptyStyle, - indexOfLayer, generateId, getAccessToken, replaceAccessTokens, 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"],