mirror of
https://github.com/maputnik/editor.git
synced 2026-07-26 15:57:27 +00:00
Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b09c629be4 | |||
| 89be954485 | |||
| 83c099bef4 | |||
| 382ba69134 | |||
| dbe5d3916d | |||
| 373b57a6f4 | |||
| f2579e2a19 | |||
| aa9530274f | |||
| 0ba7b3704f | |||
| be11f75c9f | |||
| 03a41cf17a | |||
| 192df96a79 | |||
| 10d47c561b | |||
| c22f7404e7 | |||
| 36073b2b1f | |||
| ca66f47be0 | |||
| a5c47437ee | |||
| 91a6335d09 | |||
| acaa350f0f | |||
| 1a5c879c75 | |||
| b16776fed3 | |||
| d039dc73a5 | |||
| 70a2db580f | |||
| b76d9cbe29 | |||
| dd04c14d96 | |||
| 6c31add041 | |||
| 60ed80651c | |||
| c642ec5325 | |||
| 0c98378243 | |||
| 95ba3c4428 | |||
| b112c6a2bf | |||
| a251faf8f7 | |||
| 8580499ec3 | |||
| 56a9840def | |||
| 3d1919738b | |||
| cc317c20a1 | |||
| a9c3da0f40 | |||
| dc5d6dd77d | |||
| 95d87ad7ef | |||
| 09efbb5cd6 | |||
| 4f046cb83e | |||
| fb00dea285 | |||
| c00395ee11 | |||
| 5092a924aa | |||
| 7d80dccb81 | |||
| a93fe4d78b | |||
| 603a30cba8 | |||
| 6f55fda7d4 | |||
| 8f0ee898ff | |||
| 3896c4c187 | |||
| c0f1a72d80 | |||
| ac9186e7f8 | |||
| 8c86607100 | |||
| 7c5e5358cb | |||
| 18b34d3ecd | |||
| 08ebca266c | |||
| 8344a30f5d | |||
| 4249e9beb9 | |||
| c3dd253737 | |||
| f7b48139a4 | |||
| 8af1cfd5f8 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
`<input>`; 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 `<key>-text` and `<key>-range` when `allowRange` is set,
|
||||
and `<key>` 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).
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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": []
|
||||
}
|
||||
+367
-9
@@ -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();
|
||||
|
||||
+16
-3
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
@@ -128,6 +131,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 +177,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. */
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+188
-26
@@ -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.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 +81,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 +110,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 +144,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 +332,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 +341,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 +350,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 +359,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 +477,6 @@ describe("modals", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("sources placeholder", () => {
|
||||
test.skip("toggle", () => {});
|
||||
});
|
||||
|
||||
describe("global state", () => {
|
||||
beforeEach(async () => {
|
||||
await when.click("nav:global-state");
|
||||
|
||||
@@ -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<string, unknown>): 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<string, unknown> | unknown[]): void {
|
||||
expect(actual).toMatchObject(expected);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,7 +131,7 @@ export class Assertable<T> {
|
||||
}
|
||||
});
|
||||
|
||||
shouldDeepNestedInclude = (value: Record<string, unknown>) =>
|
||||
shouldDeepNestedInclude = (value: Record<string, unknown> | unknown[]) =>
|
||||
this.assertValue((actual) => assertDeepNestedInclude(actual, value));
|
||||
}
|
||||
|
||||
@@ -144,6 +145,8 @@ async function typeSequence(page: Page, text: string): Promise<void> {
|
||||
del: "Delete",
|
||||
tab: "Tab",
|
||||
home: "Home",
|
||||
end: "End",
|
||||
rightarrow: "ArrowRight",
|
||||
};
|
||||
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
@@ -202,6 +205,15 @@ export class PlaywrightHelper {
|
||||
return new Query<T>(getter);
|
||||
}
|
||||
|
||||
/** Stubs the File System Access "save" picker so file saves complete headlessly. */
|
||||
public stubSaveFilePicker(): Promise<void> {
|
||||
return this.page.evaluate(() => {
|
||||
(window as any).showSaveFilePicker = async () => ({
|
||||
createWritable: async () => ({ write: async () => {}, close: async () => {} }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Entry point for fluent assertions over a Locator or a value/Query. */
|
||||
public then = <T>(target: T): Assertable<T> => new Assertable(target);
|
||||
|
||||
|
||||
Generated
+29
-175
@@ -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",
|
||||
|
||||
+1
-4
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ import ModalDebug from "./modals/ModalDebug";
|
||||
import ModalGlobalState from "./modals/ModalGlobalState";
|
||||
|
||||
import {downloadGlyphsMetadata, downloadSpriteMetadata} from "../libs/metadata";
|
||||
import style from "../libs/style";
|
||||
import { emptyStyle, getAccessToken, replaceAccessTokens } from "../libs/style";
|
||||
import { undoMessages, redoMessages } from "../libs/diffmessage";
|
||||
import { createStyleStore, type IStyleStore } from "../libs/store/style-store-factory";
|
||||
import { RevisionStore } from "../libs/revisions";
|
||||
@@ -48,19 +48,19 @@ function setFetchAccessToken(url: string, mapStyle: StyleSpecification) {
|
||||
const matchesThunderforest = url.match(/\.thunderforest\.com/);
|
||||
const matchesLocationIQ = url.match(/\.locationiq\.com/);
|
||||
if (matchesTilehosting || matchesMaptiler) {
|
||||
const accessToken = style.getAccessToken("openmaptiles", mapStyle, {allowFallback: true});
|
||||
const accessToken = getAccessToken("openmaptiles", mapStyle, {allowFallback: true});
|
||||
if (accessToken) {
|
||||
return url.replace("{key}", accessToken);
|
||||
}
|
||||
}
|
||||
else if (matchesThunderforest) {
|
||||
const accessToken = style.getAccessToken("thunderforest", mapStyle, {allowFallback: true});
|
||||
const accessToken = getAccessToken("thunderforest", mapStyle, {allowFallback: true});
|
||||
if (accessToken) {
|
||||
return url.replace("{key}", accessToken);
|
||||
}
|
||||
}
|
||||
else if (matchesLocationIQ) {
|
||||
const accessToken = style.getAccessToken("locationiq", mapStyle, {allowFallback: true});
|
||||
const accessToken = getAccessToken("locationiq", mapStyle, {allowFallback: true});
|
||||
if (accessToken) {
|
||||
return url.replace("{key}", accessToken);
|
||||
}
|
||||
@@ -137,7 +137,7 @@ export default class App extends React.Component<any, AppState> {
|
||||
this.state = {
|
||||
errors: [],
|
||||
infos: [],
|
||||
mapStyle: style.emptyStyle,
|
||||
mapStyle: emptyStyle,
|
||||
selectedLayerIndex: 0,
|
||||
sources: {},
|
||||
vectorLayers: {},
|
||||
@@ -698,7 +698,7 @@ export default class App extends React.Component<any, AppState> {
|
||||
mapStyle: (dirtyMapStyle || mapStyle),
|
||||
mapView: this.state.mapView,
|
||||
replaceAccessTokens: (mapStyle: StyleSpecification) => {
|
||||
return style.replaceAccessTokens(mapStyle, {
|
||||
return replaceAccessTokens(mapStyle, {
|
||||
allowFallback: true
|
||||
});
|
||||
},
|
||||
|
||||
@@ -215,6 +215,7 @@ class FilterEditorInternal extends React.Component<FilterEditorInternalProps, Fi
|
||||
onClick={this.makeExpression}
|
||||
title={t("Convert to expression")}
|
||||
className="maputnik-make-zoom-function"
|
||||
data-wd-key="filter-convert-to-expression"
|
||||
>
|
||||
<TbMathFunction />
|
||||
</InputButton>
|
||||
@@ -248,6 +249,7 @@ class FilterEditorInternal extends React.Component<FilterEditorInternalProps, Fi
|
||||
fieldSpec={fieldSpec}
|
||||
label={t("Filter")}
|
||||
action={actions}
|
||||
data-wd-key="filter-combining-operator"
|
||||
>
|
||||
<InputSelect
|
||||
value={combiningOp}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { render } from "vitest-browser-react";
|
||||
import { page } from "vitest/browser";
|
||||
import InputAutocomplete from "./InputAutocomplete";
|
||||
|
||||
const fruits = ["apple", "banana", "cherry"];
|
||||
|
||||
test("filters options when typing", async () => {
|
||||
render(<InputAutocomplete aria-label="Fruit" options={fruits.map((f) => [f, f])} />);
|
||||
|
||||
const input = page.getByLabelText("Fruit");
|
||||
await input.click();
|
||||
|
||||
const menuItems = page.getByRole("option");
|
||||
await expect.element(menuItems.first()).toBeVisible();
|
||||
expect(menuItems.all()).toHaveLength(3);
|
||||
|
||||
await input.fill("ch");
|
||||
await expect.element(page.getByText("cherry")).toBeVisible();
|
||||
expect(page.getByRole("option").all()).toHaveLength(1);
|
||||
|
||||
await page.getByText("cherry").click();
|
||||
await expect.element(input).toHaveValue("cherry");
|
||||
});
|
||||
@@ -230,6 +230,7 @@ class LayerEditorInternal extends React.Component<LayerEditorInternalProps, Laye
|
||||
)}
|
||||
/>
|
||||
{this.props.layer.type !== "background" && <FieldSource
|
||||
wdKey="layer-editor.layer-source"
|
||||
error={errorData.source}
|
||||
sourceIds={Object.keys(this.props.sources!)}
|
||||
value={this.props.layer.source}
|
||||
|
||||
@@ -289,6 +289,7 @@ class DataPropertyInternal extends React.Component<DataPropertyInternalProps, Da
|
||||
<Block
|
||||
label={t("Function")}
|
||||
key="function"
|
||||
data-wd-key="function-type"
|
||||
>
|
||||
<div className="maputnik-data-spec-property-input">
|
||||
<InputSelect
|
||||
@@ -303,6 +304,7 @@ class DataPropertyInternal extends React.Component<DataPropertyInternalProps, Da
|
||||
<Block
|
||||
label={t("Base")}
|
||||
key="base"
|
||||
data-wd-key="function-base"
|
||||
>
|
||||
<div className="maputnik-data-spec-property-input">
|
||||
<InputSpec
|
||||
@@ -317,6 +319,7 @@ class DataPropertyInternal extends React.Component<DataPropertyInternalProps, Da
|
||||
<Block
|
||||
label={"Property"}
|
||||
key="property"
|
||||
data-wd-key="function-property"
|
||||
>
|
||||
<div className="maputnik-data-spec-property-input">
|
||||
<InputString
|
||||
@@ -330,6 +333,7 @@ class DataPropertyInternal extends React.Component<DataPropertyInternalProps, Da
|
||||
<Block
|
||||
label={t("Default")}
|
||||
key="default"
|
||||
data-wd-key="function-default"
|
||||
>
|
||||
<InputSpec
|
||||
fieldName={this.props.fieldName}
|
||||
@@ -368,6 +372,7 @@ class DataPropertyInternal extends React.Component<DataPropertyInternalProps, Da
|
||||
}
|
||||
<InputButton
|
||||
className="maputnik-add-stop"
|
||||
data-wd-key="convert-to-expression"
|
||||
onClick={this.props.onExpressionClick?.bind(this)}
|
||||
>
|
||||
<TbMathFunction style={{ verticalAlign: "text-bottom" }} />
|
||||
|
||||
@@ -50,6 +50,7 @@ class ExpressionPropertyInternal extends React.Component<ExpressionPropertyInter
|
||||
onClick={this.props.onUndo}
|
||||
disabled={undoDisabled}
|
||||
className="maputnik-delete-stop"
|
||||
data-wd-key="undo-expression"
|
||||
title={t("Revert from expression")}
|
||||
>
|
||||
<MdUndo />
|
||||
@@ -59,6 +60,7 @@ class ExpressionPropertyInternal extends React.Component<ExpressionPropertyInter
|
||||
key="delete_action"
|
||||
onClick={this.props.onDelete}
|
||||
className="maputnik-delete-stop"
|
||||
data-wd-key="delete-expression"
|
||||
title={t("Delete expression")}
|
||||
>
|
||||
<MdDelete />
|
||||
|
||||
@@ -194,6 +194,7 @@ class ZoomPropertyInternal extends React.Component<ZoomPropertyInternalProps, Zo
|
||||
<div className="maputnik-data-fieldset-inner">
|
||||
<Block
|
||||
label={t("Function")}
|
||||
data-wd-key="function-type"
|
||||
>
|
||||
<div className="maputnik-data-spec-property-input">
|
||||
<InputSelect
|
||||
@@ -206,6 +207,7 @@ class ZoomPropertyInternal extends React.Component<ZoomPropertyInternalProps, Zo
|
||||
</Block>
|
||||
<Block
|
||||
label={t("Base")}
|
||||
data-wd-key="function-base"
|
||||
>
|
||||
<div className="maputnik-data-spec-property-input">
|
||||
<InputSpec
|
||||
@@ -240,6 +242,7 @@ class ZoomPropertyInternal extends React.Component<ZoomPropertyInternalProps, Zo
|
||||
</InputButton>
|
||||
<InputButton
|
||||
className="maputnik-add-stop"
|
||||
data-wd-key="convert-to-expression"
|
||||
onClick={this.props.onExpressionClick?.bind(this)}
|
||||
>
|
||||
<TbMathFunction style={{ verticalAlign: "text-bottom" }} />
|
||||
|
||||
@@ -9,7 +9,7 @@ import {type WithTranslation, withTranslation} from "react-i18next";
|
||||
import FieldString from "../FieldString";
|
||||
import InputButton from "../InputButton";
|
||||
import Modal from "./Modal";
|
||||
import style from "../../libs/style";
|
||||
import {replaceAccessTokens, stripAccessTokens} from "../../libs/style";
|
||||
import fieldSpecAdditional from "../../libs/field-spec-additional";
|
||||
import type {OnStyleChangedCallback, StyleSpecificationWithId} from "../../libs/definitions";
|
||||
|
||||
@@ -32,8 +32,8 @@ class ModalExportInternal extends React.Component<ModalExportInternalProps> {
|
||||
|
||||
tokenizedStyle() {
|
||||
return format(
|
||||
style.stripAccessTokens(
|
||||
style.replaceAccessTokens(this.props.mapStyle)
|
||||
stripAccessTokens(
|
||||
replaceAccessTokens(this.props.mapStyle)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import Modal from "./Modal";
|
||||
import InputButton from "../InputButton";
|
||||
import InputUrl from "../InputUrl";
|
||||
|
||||
import style from "../../libs/style";
|
||||
import { ensureStyleValidity } from "../../libs/style";
|
||||
import publicStyles from "../../config/styles.json";
|
||||
|
||||
type PublicStyleProps = {
|
||||
@@ -109,7 +109,7 @@ class ModalOpenInternal extends React.Component<ModalOpenInternalProps, ModalOpe
|
||||
activeRequestUrl: null
|
||||
});
|
||||
|
||||
const mapStyle = style.ensureStyleValidity(body);
|
||||
const mapStyle = ensureStyleValidity(body);
|
||||
console.log("Loaded style ", mapStyle.id);
|
||||
this.props.onStyleOpen(mapStyle);
|
||||
this.onOpenToggle();
|
||||
@@ -165,7 +165,7 @@ class ModalOpenInternal extends React.Component<ModalOpenInternalProps, ModalOpe
|
||||
});
|
||||
return;
|
||||
}
|
||||
mapStyle = style.ensureStyleValidity(mapStyle);
|
||||
mapStyle = ensureStyleValidity(mapStyle);
|
||||
|
||||
this.props.onStyleOpen(mapStyle, fileHandle);
|
||||
this.onOpenToggle();
|
||||
@@ -193,7 +193,7 @@ class ModalOpenInternal extends React.Component<ModalOpenInternalProps, ModalOpe
|
||||
});
|
||||
return;
|
||||
}
|
||||
mapStyle = style.ensureStyleValidity(mapStyle);
|
||||
mapStyle = ensureStyleValidity(mapStyle);
|
||||
this.props.onStyleOpen(mapStyle);
|
||||
this.onOpenToggle();
|
||||
};
|
||||
|
||||
@@ -206,6 +206,7 @@ class ModalSettingsInternal extends React.Component<ModalSettingsInternalProps>
|
||||
|
||||
<FieldNumber
|
||||
label={t("Zoom")}
|
||||
data-wd-key="modal:settings.zoom"
|
||||
fieldSpec={latest.$root.zoom}
|
||||
value={mapStyle.zoom}
|
||||
default={0}
|
||||
@@ -214,6 +215,7 @@ class ModalSettingsInternal extends React.Component<ModalSettingsInternalProps>
|
||||
|
||||
<FieldNumber
|
||||
label={t("Bearing")}
|
||||
data-wd-key="modal:settings.bearing"
|
||||
fieldSpec={latest.$root.bearing}
|
||||
value={mapStyle.bearing}
|
||||
default={latest.$root.bearing.default}
|
||||
@@ -222,6 +224,7 @@ class ModalSettingsInternal extends React.Component<ModalSettingsInternalProps>
|
||||
|
||||
<FieldNumber
|
||||
label={t("Pitch")}
|
||||
data-wd-key="modal:settings.pitch"
|
||||
fieldSpec={latest.$root.pitch}
|
||||
value={mapStyle.pitch}
|
||||
default={latest.$root.pitch.default}
|
||||
@@ -248,6 +251,7 @@ class ModalSettingsInternal extends React.Component<ModalSettingsInternalProps>
|
||||
|
||||
<FieldNumber
|
||||
label={t("Light intensity")}
|
||||
data-wd-key="modal:settings.light-intensity"
|
||||
fieldSpec={latest.light.intensity}
|
||||
value={light.intensity as number}
|
||||
default={latest.light.intensity.default}
|
||||
@@ -274,6 +278,7 @@ class ModalSettingsInternal extends React.Component<ModalSettingsInternalProps>
|
||||
|
||||
<FieldNumber
|
||||
label={t("Terrain exaggeration")}
|
||||
data-wd-key="modal:settings.terrain-exaggeration"
|
||||
fieldSpec={latest.terrain.exaggeration}
|
||||
value={terrain.exaggeration}
|
||||
default={latest.terrain.exaggeration.default}
|
||||
@@ -282,6 +287,7 @@ class ModalSettingsInternal extends React.Component<ModalSettingsInternalProps>
|
||||
|
||||
<FieldNumber
|
||||
label={t("Transition delay")}
|
||||
data-wd-key="modal:settings.transition-delay"
|
||||
fieldSpec={latest.transition.delay}
|
||||
value={transition.delay}
|
||||
default={latest.transition.delay.default}
|
||||
@@ -290,6 +296,7 @@ class ModalSettingsInternal extends React.Component<ModalSettingsInternalProps>
|
||||
|
||||
<FieldNumber
|
||||
label={t("Transition duration")}
|
||||
data-wd-key="modal:settings.transition-duration"
|
||||
fieldSpec={latest.transition.duration}
|
||||
value={transition.duration}
|
||||
default={latest.transition.duration.default}
|
||||
|
||||
@@ -10,7 +10,7 @@ import FieldString from "../FieldString";
|
||||
import FieldSelect from "../FieldSelect";
|
||||
import ModalSourcesTypeEditor, { type EditorMode } from "./ModalSourcesTypeEditor";
|
||||
|
||||
import style from "../../libs/style";
|
||||
import { generateId } from "../../libs/style";
|
||||
import { deleteSource, addSource, changeSource } from "../../libs/source";
|
||||
import publicSources from "../../config/tilesets.json";
|
||||
import { type OnStyleChangedCallback, type StyleSpecificationWithId } from "../../libs/definitions";
|
||||
@@ -121,7 +121,7 @@ class AddSource extends React.Component<AddSourceProps, AddSourceState> {
|
||||
super(props);
|
||||
this.state = {
|
||||
mode: "tilejson_vector",
|
||||
sourceId: style.generateId(),
|
||||
sourceId: generateId(),
|
||||
source: this.defaultSource("tilejson_vector"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { LayerSpecification } from "maplibre-gl";
|
||||
import { colorHighlightedLayer } from "./highlight";
|
||||
|
||||
function layer(overrides: Record<string, any>): LayerSpecification {
|
||||
return { id: "l", source: "s", "source-layer": "sl", ...overrides } as unknown as LayerSpecification;
|
||||
}
|
||||
|
||||
describe("colorHighlightedLayer", () => {
|
||||
it("returns null for undefined, background and raster layers", () => {
|
||||
expect(colorHighlightedLayer(undefined)).toBeNull();
|
||||
expect(colorHighlightedLayer(layer({ type: "background" }))).toBeNull();
|
||||
expect(colorHighlightedLayer(layer({ type: "raster" }))).toBeNull();
|
||||
});
|
||||
|
||||
it("builds a circle highlight for circle and symbol layers", () => {
|
||||
for (const type of ["circle", "symbol"]) {
|
||||
const highlight = colorHighlightedLayer(layer({ type }))!;
|
||||
expect(highlight).not.toBeNull();
|
||||
expect(highlight.type).toBe("circle");
|
||||
expect(highlight.id).toMatch(/_highlight$/);
|
||||
expect((highlight.paint as any)["circle-radius"]).toBe(3);
|
||||
}
|
||||
});
|
||||
|
||||
it("builds a line highlight with an overridden width", () => {
|
||||
const highlight = colorHighlightedLayer(layer({ type: "line" }))!;
|
||||
expect(highlight.type).toBe("line");
|
||||
expect((highlight.paint as any)["line-width"]).toBe(2);
|
||||
});
|
||||
|
||||
it("builds a polygon highlight for fill and fill-extrusion layers", () => {
|
||||
for (const type of ["fill", "fill-extrusion"]) {
|
||||
const highlight = colorHighlightedLayer(layer({ type }))!;
|
||||
expect(highlight.type).toBe("fill");
|
||||
}
|
||||
});
|
||||
|
||||
it("copies the source layer's filter when present, drops it otherwise", () => {
|
||||
const withFilter = colorHighlightedLayer(
|
||||
layer({ type: "line", filter: ["==", "class", "road"] } as any)
|
||||
)!;
|
||||
expect(withFilter.filter).toEqual(["==", "class", "road"]);
|
||||
|
||||
const withoutFilter = colorHighlightedLayer(layer({ type: "line" }))!;
|
||||
expect("filter" in withoutFilter).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { LayerSpecification } from "maplibre-gl";
|
||||
import { changeType, changeProperty, layerPrefix, findClosestCommonPrefix } from "./layer";
|
||||
|
||||
describe("changeType", () => {
|
||||
it("drops paint/layout props not valid for the new type", () => {
|
||||
const layer = {
|
||||
id: "l",
|
||||
type: "fill",
|
||||
source: "s",
|
||||
paint: { "fill-color": "#fff", "fill-opacity": 0.5 },
|
||||
layout: { visibility: "visible" },
|
||||
} as unknown as LayerSpecification;
|
||||
|
||||
const changed = changeType(layer, "background") as any;
|
||||
expect(changed.type).toBe("background");
|
||||
// fill-color is not a valid background paint property
|
||||
expect(changed.paint["fill-color"]).toBeUndefined();
|
||||
// visibility is valid for background layout
|
||||
expect(changed.layout.visibility).toBe("visible");
|
||||
});
|
||||
|
||||
it("keeps props that are valid for the new type", () => {
|
||||
const layer = {
|
||||
id: "l",
|
||||
type: "line",
|
||||
source: "s",
|
||||
paint: { "line-color": "#000" },
|
||||
} as unknown as LayerSpecification;
|
||||
const changed = changeType(layer, "line") as any;
|
||||
expect(changed.paint["line-color"]).toBe("#000");
|
||||
});
|
||||
});
|
||||
|
||||
describe("changeProperty", () => {
|
||||
const base = { id: "l", type: "background" } as unknown as LayerSpecification;
|
||||
|
||||
it("sets a top-level property", () => {
|
||||
const changed = changeProperty(base, null, "minzoom", 4) as any;
|
||||
expect(changed.minzoom).toBe(4);
|
||||
});
|
||||
|
||||
it("removes a top-level property when value is undefined", () => {
|
||||
const changed = changeProperty({ ...base, minzoom: 4 } as any, null, "minzoom", undefined) as any;
|
||||
expect("minzoom" in changed).toBe(false);
|
||||
});
|
||||
|
||||
it("sets a grouped property", () => {
|
||||
const changed = changeProperty(base, "paint", "background-color", "#fff") as any;
|
||||
expect(changed.paint["background-color"]).toBe("#fff");
|
||||
});
|
||||
|
||||
it("removes a grouped property and drops the empty group", () => {
|
||||
const layer = { ...base, paint: { "background-color": "#fff" } } as any;
|
||||
const changed = changeProperty(layer, "paint", "background-color", undefined) as any;
|
||||
expect("paint" in changed).toBe(false);
|
||||
});
|
||||
|
||||
it("removes a grouped property but keeps a non-empty group", () => {
|
||||
const layer = { ...base, paint: { "background-color": "#fff", "background-opacity": 1 } } as any;
|
||||
const changed = changeProperty(layer, "paint", "background-color", undefined) as any;
|
||||
expect(changed.paint["background-color"]).toBeUndefined();
|
||||
expect(changed.paint["background-opacity"]).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("layerPrefix", () => {
|
||||
it("returns the segment before the first separator", () => {
|
||||
expect(layerPrefix("foo-bar")).toBe("foo");
|
||||
expect(layerPrefix("foo_bar")).toBe("foo");
|
||||
expect(layerPrefix("foo bar")).toBe("foo");
|
||||
expect(layerPrefix("single")).toBe("single");
|
||||
});
|
||||
});
|
||||
|
||||
describe("findClosestCommonPrefix", () => {
|
||||
const layers = [
|
||||
{ id: "aa" },
|
||||
{ id: "aa-2" },
|
||||
{ id: "b" },
|
||||
] as unknown as LayerSpecification[];
|
||||
|
||||
it("finds the first index of a run sharing the prefix", () => {
|
||||
expect(findClosestCommonPrefix(layers, 1)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns the index itself when the previous prefix differs", () => {
|
||||
expect(findClosestCommonPrefix(layers, 2)).toBe(2);
|
||||
});
|
||||
|
||||
it("returns the index for the first layer", () => {
|
||||
expect(findClosestCommonPrefix(layers, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import 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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import sortNumerically from "./sort-numerically";
|
||||
|
||||
describe("sortNumerically", () => {
|
||||
it("orders numbers (and numeric strings) ascending", () => {
|
||||
expect([3, 1, 2].sort(sortNumerically)).toEqual([1, 2, 3]);
|
||||
expect(["10", "2", "1"].sort(sortNumerically)).toEqual(["1", "2", "10"]);
|
||||
expect(sortNumerically(5, 5)).toBe(0);
|
||||
expect(sortNumerically(1, 2)).toBe(-1);
|
||||
expect(sortNumerically(2, 1)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { findDefaultFromSpec } from "./spec-helper";
|
||||
|
||||
describe("findDefaultFromSpec", () => {
|
||||
it("returns the spec default when present (even if falsy)", () => {
|
||||
expect(findDefaultFromSpec({ type: "string", default: "hi" })).toBe("hi");
|
||||
expect(findDefaultFromSpec({ type: "boolean", default: false })).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to a sensible default per type", () => {
|
||||
expect(findDefaultFromSpec({ type: "color" })).toBe("#000000");
|
||||
expect(findDefaultFromSpec({ type: "string" })).toBe("");
|
||||
// The falsy `false` fallback collapses to "" via the `|| ""` in the impl.
|
||||
expect(findDefaultFromSpec({ type: "boolean" })).toBe("");
|
||||
expect(findDefaultFromSpec({ type: "array" })).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
|
||||
vi.stubGlobal("window", { location: { port: "8000" } });
|
||||
|
||||
// Avoid opening a real websocket in notifyLocalChanges().
|
||||
const wsInstances: any[] = [];
|
||||
vi.mock("reconnecting-websocket", () => ({
|
||||
default: class {
|
||||
onmessage: ((e: { data: string }) => void) | null = null;
|
||||
constructor(public url: string) {
|
||||
wsInstances.push(this);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import { ApiStyleStore } from "./apistore";
|
||||
|
||||
const okJson = (body: any) => ({ ok: true, json: async () => body });
|
||||
|
||||
describe("ApiStyleStore", () => {
|
||||
beforeEach(() => {
|
||||
wsInstances.length = 0;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("init fetches style ids and wires up local change notifications", async () => {
|
||||
const fetchMock = vi.fn(async () => okJson(["style-a", "style-b"]) as any);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const onLocalStyleChange = vi.fn();
|
||||
const store = new ApiStyleStore({ onLocalStyleChange });
|
||||
|
||||
await store.init();
|
||||
expect(fetchMock).toHaveBeenCalledWith("http://localhost:8000/styles", { mode: "cors" });
|
||||
expect(store.latestStyleId).toBe("style-a");
|
||||
|
||||
// A websocket message triggers onLocalStyleChange with a valid style.
|
||||
expect(wsInstances.length).toBe(1);
|
||||
wsInstances[0].onmessage!({ data: JSON.stringify({ version: 8, sources: {}, layers: [] }) });
|
||||
expect(onLocalStyleChange).toHaveBeenCalledOnce();
|
||||
expect(onLocalStyleChange.mock.calls[0][0].id).toBeTruthy();
|
||||
});
|
||||
|
||||
it("init throws a friendly error when the API is unreachable", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("boom"); }));
|
||||
const store = new ApiStyleStore({});
|
||||
await expect(store.init()).rejects.toThrow("Can not connect to style API");
|
||||
});
|
||||
|
||||
it("getLatestStyle fetches the latest style and validates it", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(async () => okJson(["s1"]) as any));
|
||||
const store = new ApiStyleStore({});
|
||||
await store.init();
|
||||
|
||||
vi.stubGlobal("fetch", vi.fn(async () => okJson({ version: 8, id: "s1", sources: {}, layers: [] }) as any));
|
||||
const latest = await store.getLatestStyle();
|
||||
expect(latest.id).toBe("s1");
|
||||
});
|
||||
|
||||
it("getLatestStyle throws when not initialised", async () => {
|
||||
const store = new ApiStyleStore({});
|
||||
await expect(store.getLatestStyle()).rejects.toThrow(/init the api backend/);
|
||||
});
|
||||
|
||||
it("save PUTs the (token-stripped) style to the API", () => {
|
||||
const fetchMock = vi.fn(async () => okJson({}) as any);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const store = new ApiStyleStore({});
|
||||
const mapStyle = { version: 8, id: "s1", sources: {}, layers: [] } as any;
|
||||
|
||||
expect(store.save(mapStyle)).toBe(mapStyle);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://localhost:8000/styles/s1",
|
||||
expect.objectContaining({ method: "PUT", mode: "cors" })
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import style from "../style";
|
||||
import {emptyStyle, ensureStyleValidity, replaceAccessTokens, stripAccessTokens} from "../style";
|
||||
import {format} from "@maplibre/maplibre-gl-style-spec";
|
||||
import ReconnectingWebSocket from "reconnecting-websocket";
|
||||
import type {IStyleStore, OnStyleChangedCallback, StyleSpecificationWithId} from "../definitions";
|
||||
@@ -40,13 +40,13 @@ export class ApiStyleStore implements IStyleStore {
|
||||
connection.onmessage = e => {
|
||||
if(!e.data) return;
|
||||
console.log("Received style update from API");
|
||||
let parsedStyle = style.emptyStyle;
|
||||
let parsedStyle = emptyStyle;
|
||||
try {
|
||||
parsedStyle = JSON.parse(e.data);
|
||||
} catch(err) {
|
||||
console.error(err);
|
||||
}
|
||||
const updatedStyle = style.ensureStyleValidity(parsedStyle);
|
||||
const updatedStyle = ensureStyleValidity(parsedStyle);
|
||||
this.onLocalStyleChange(updatedStyle);
|
||||
};
|
||||
}
|
||||
@@ -57,7 +57,7 @@ export class ApiStyleStore implements IStyleStore {
|
||||
mode: "cors",
|
||||
});
|
||||
const body = await response.json();
|
||||
return style.ensureStyleValidity(body);
|
||||
return ensureStyleValidity(body);
|
||||
} else {
|
||||
throw new Error("No latest style available. You need to init the api backend first.");
|
||||
}
|
||||
@@ -66,8 +66,8 @@ export class ApiStyleStore implements IStyleStore {
|
||||
// Save current style replacing previous version
|
||||
save(mapStyle: StyleSpecificationWithId) {
|
||||
const styleJSON = format(
|
||||
style.stripAccessTokens(
|
||||
style.replaceAccessTokens(mapStyle)
|
||||
stripAccessTokens(
|
||||
replaceAccessTokens(mapStyle)
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { StyleStore } from "./stylestore";
|
||||
|
||||
class LocalStorageMock {
|
||||
private store: Record<string, string> = {};
|
||||
get length() {
|
||||
return Object.keys(this.store).length;
|
||||
}
|
||||
key(i: number) {
|
||||
return Object.keys(this.store)[i] ?? null;
|
||||
}
|
||||
getItem(k: string) {
|
||||
return k in this.store ? this.store[k] : null;
|
||||
}
|
||||
setItem(k: string, v: string) {
|
||||
this.store[k] = v;
|
||||
}
|
||||
removeItem(k: string) {
|
||||
delete this.store[k];
|
||||
}
|
||||
clear() {
|
||||
this.store = {};
|
||||
}
|
||||
}
|
||||
vi.stubGlobal("window", { localStorage: new LocalStorageMock() });
|
||||
// loadDefaultStyle fetches the default style over the network; serve it locally.
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({
|
||||
json: async () => ({ version: 8, id: "default", sources: {}, layers: [] }),
|
||||
}))
|
||||
);
|
||||
|
||||
const style = (id: string) => ({ version: 8, id, sources: {}, layers: [] }) as any;
|
||||
|
||||
describe("StyleStore", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it("returns the default style when nothing is stored", async () => {
|
||||
const store = new StyleStore();
|
||||
const latest = await store.getLatestStyle();
|
||||
expect(latest.id).toBe("default");
|
||||
});
|
||||
|
||||
it("saves a style and reads it back as the latest", async () => {
|
||||
const store = new StyleStore();
|
||||
store.save(style("abc"));
|
||||
// A fresh store discovers the persisted style ids.
|
||||
const reopened = new StyleStore();
|
||||
const latest = await reopened.getLatestStyle();
|
||||
expect(latest.id).toBe("abc");
|
||||
});
|
||||
|
||||
it("purge removes all maputnik keys", async () => {
|
||||
const store = new StyleStore();
|
||||
store.save(style("abc"));
|
||||
window.localStorage.setItem("unrelated", "keep");
|
||||
store.purge();
|
||||
expect(window.localStorage.getItem("maputnik:style:abc")).toBeNull();
|
||||
expect(window.localStorage.getItem("unrelated")).toBe("keep");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import style from "../style";
|
||||
import {ensureStyleValidity} from "../style";
|
||||
import {loadStyleUrl} from "../urlopen";
|
||||
import publicSources from "../../config/styles.json";
|
||||
import type {IStyleStore, StyleSpecificationWithId} from "../definitions";
|
||||
@@ -89,7 +89,7 @@ export class StyleStore implements IStyleStore {
|
||||
|
||||
// Save current style replacing previous version
|
||||
save(mapStyle: StyleSpecificationWithId) {
|
||||
mapStyle = style.ensureStyleValidity(mapStyle);
|
||||
mapStyle = ensureStyleValidity(mapStyle);
|
||||
const key = styleKey(mapStyle.id);
|
||||
|
||||
const saveFn = () => {
|
||||
|
||||
+1
-11
@@ -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`];
|
||||
@@ -148,10 +139,9 @@ function stripAccessTokens(mapStyle: StyleSpecification) {
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
export {
|
||||
ensureStyleValidity,
|
||||
emptyStyle,
|
||||
indexOfLayer,
|
||||
generateId,
|
||||
getAccessToken,
|
||||
replaceAccessTokens,
|
||||
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
import style from "./style";
|
||||
import { emptyStyle, ensureStyleValidity } from "./style";
|
||||
import { type StyleSpecificationWithId } from "./definitions";
|
||||
|
||||
export function getStyleUrlFromAddressbarAndRemoveItIfNeeded(): string | null {
|
||||
@@ -19,10 +19,10 @@ export async function loadStyleUrl(styleUrl: string): Promise<StyleSpecification
|
||||
credentials: "same-origin"
|
||||
});
|
||||
const body = await response.json();
|
||||
return style.ensureStyleValidity(body);
|
||||
return ensureStyleValidity(body);
|
||||
} catch {
|
||||
console.warn("Could not fetch default style: " + styleUrl);
|
||||
return style.emptyStyle;
|
||||
return emptyStyle;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-27
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user