Compare commits

...

19 Commits

Author SHA1 Message Date
HarelM b09c629be4 Update agents.md file with the relevant information. 2026-07-12 11:16:49 +03:00
HarelM 89be954485 Improve unit test. 2026-07-12 11:08:54 +03:00
pre-commit-ci[bot] 83c099bef4 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-12 07:52:25 +00:00
HarelM 382ba69134 Add access token tests. 2026-07-12 10:52:10 +03:00
HarelM dbe5d3916d Remove redundant comment 2026-07-12 10:46:13 +03:00
HarelM 373b57a6f4 Improve tests 2026-07-12 10:43:03 +03:00
HarelM f2579e2a19 Split tests 2026-07-12 10:36:47 +03:00
pre-commit-ci[bot] aa9530274f [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-12 07:25:39 +00:00
HarelM 0ba7b3704f Restore urlopen test file. 2026-07-12 10:24:55 +03:00
HarelM be11f75c9f Add more end to end tests to increase coverage 2026-07-12 10:23:17 +03:00
HarelM 03a41cf17a fix import style 2026-07-12 10:05:02 +03:00
HarelM 192df96a79 import style 2026-07-12 10:04:01 +03:00
HarelM 10d47c561b Remove unneeded unit tests. 2026-07-12 10:02:36 +03:00
HarelM c22f7404e7 Add more end-to-end tests 2026-07-12 09:34:58 +03:00
HarelM 36073b2b1f Fix build 2026-07-12 09:34:58 +03:00
HarelM ca66f47be0 fix build 2026-07-12 09:34:58 +03:00
HarelM a5c47437ee Remove dead code 2026-07-12 09:34:58 +03:00
Harel M 91a6335d09 Apply suggestion from @HarelM 2026-07-12 09:00:50 +03:00
Harel M acaa350f0f Apply suggestion from @HarelM 2026-07-12 08:59:43 +03:00
29 changed files with 890 additions and 376 deletions
+104 -1
View File
@@ -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).
+60
View File
@@ -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");
});
});
+22
View File
@@ -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": []
}
+319 -74
View File
@@ -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();
@@ -78,7 +89,6 @@ describe("layer editor", () => {
});
test("the range slider adjusts min-zoom", async () => {
// The slider starts at 1 (set via the text input above); one step right lands on 2.
await when.focus("min-zoom.input-range");
await when.typeKeys("{rightarrow}");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
@@ -182,103 +192,285 @@ describe("layer editor", () => {
});
describe("filter", () => {
test("compound filter", async () => {
const id = await when.modal.fillLayers({ type: "fill", layer: "example" });
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", ""]] }],
});
});
// Changing the operator updates the compound filter.
test("should change the filter operator", async () => {
await when.selectFilterOperator("!=");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, type: "fill", source: "example", filter: ["all", ["!=", "name", ""]] }],
layers: [{ id, filter: ["all", ["!=", "name", ""]] }],
});
});
// A second filter item extends the compound filter.
test("should extend the compound filter with a second item", async () => {
await when.addFilter();
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, type: "fill", source: "example", filter: ["all", ["!=", "name", ""], ["==", "name", ""]] }],
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", () => {
test("convert a property to a zoom function and add a stop", async () => {
const id = await when.modal.fillLayers({ type: "circle", layer: "example" });
await when.makeZoomFunction("circle-radius");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, type: "circle", source: "example", paint: { "circle-radius": { stops: [[6, 5], [10, 5]] } } }],
let id: string;
describe("zoom function", () => {
beforeEach(async () => {
id = await when.modal.fillLayers({ type: "circle", layer: "example" });
await when.makeZoomFunction("circle-radius");
});
await when.addFunctionStop("circle-radius");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, type: "circle", source: "example", paint: { "circle-radius": { stops: [[6, 5], [10, 5], [11, 5]] } } }],
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]] } } },
],
});
});
// Deleting the first stop leaves the rest.
await when.deleteFunctionStop("circle-radius");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, type: "circle", source: "example", paint: { "circle-radius": { stops: [[10, 5], [11, 5]] } } }],
test("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]] } } }],
});
});
});
});
test("convert a property to a data function and edit stops", async () => {
const id = await when.modal.fillLayers({ type: "circle", layer: "example" });
// The property needs a value before it can be turned into a data function.
await when.setValue("spec-field-input:circle-blur", "1");
await when.makeDataFunction("circle-blur");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id,
type: "circle",
source: "example",
paint: {
"circle-blur": {
property: "",
type: "exponential",
stops: [[{ zoom: 6, value: 0 }, 1], [{ zoom: 10, value: 0 }, 1]],
},
},
},
],
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");
});
await when.addFunctionStop("circle-blur");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id,
type: "circle",
source: "example",
paint: {
"circle-blur": {
property: "",
type: "exponential",
stops: [[{ zoom: 6, value: 0 }, 1], [{ zoom: 10, value: 0 }, 1], [{ zoom: 11, value: 0 }, 1]],
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]],
},
},
},
},
],
],
});
});
await when.deleteFunctionStop("circle-blur");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id,
type: "circle",
source: "example",
paint: {
"circle-blur": {
property: "",
type: "exponential",
stops: [[{ zoom: 10, value: 0 }, 1], [{ zoom: 11, value: 0 }, 1]],
test("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 } }],
});
});
});
});
@@ -296,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", () => {
@@ -319,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
View File
@@ -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", () => {
+117 -7
View File
@@ -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
@@ -180,6 +199,67 @@ export class MaputnikDriver {
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();
@@ -190,8 +270,29 @@ export class MaputnikDriver {
await this.helper.get.element(".maputnik-filter-editor-operator select").first().selectOption(value);
},
deleteFirstActiveSource: async () => {
await this.helper.get.element(".maputnik-active-source-type-editor-header-delete").first().click();
/** 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) => {
@@ -199,13 +300,22 @@ export class MaputnikDriver {
await input.fill(value);
},
exportCreateHtml: async () => {
await this.helper.get.element(".maputnik-modal-export-buttons button").last().click();
/** 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();
},
exportSaveStyle: async () => {
await this.helper.stubSaveFilePicker();
await this.helper.get.element(".maputnik-modal-export-buttons button").first().click();
/**
* 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"),
+40
View File
@@ -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();
},
};
}
+154 -9
View File
@@ -66,11 +66,11 @@ describe("modals", () => {
test("download HTML and save the style", async () => {
// Generate the standalone HTML export (triggers a file download).
await when.exportCreateHtml();
await when.modal.exportCreateHtml();
await then(get.elementByTestId("modal:export")).shouldExist();
// Saving the style closes the export modal.
await when.exportSaveStyle();
await when.modal.exportSaveStyle();
await then(get.elementByTestId("modal:export")).shouldNotExist();
});
});
@@ -82,17 +82,26 @@ describe("modals", () => {
});
test("active sources are listed and can be deleted", async () => {
// The "both" style ships with active sources; reopen the modal against it.
await when.setStyle("both");
await when.click("nav:sources");
const before = Object.keys(get.fixture("geojson-raster-style.json").sources).length;
await when.deleteFirstActiveSource();
await when.modal.deleteFirstActiveSource();
await then(
get.styleFromLocalStorage().then((style) => Object.keys(style.sources).length)
).shouldEqual(before - 1);
});
test.skip("public source", () => {});
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";
@@ -139,6 +148,108 @@ describe("modals", () => {
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]] },
},
});
});
});
describe("inspect", () => {
@@ -253,6 +364,44 @@ describe("modals", () => {
});
});
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()).shouldDeepNestedInclude({
@@ -328,10 +477,6 @@ describe("modals", () => {
});
});
describe("sources placeholder", () => {
test.skip("toggle", () => {});
});
describe("global state", () => {
beforeEach(async () => {
await when.click("nav:global-state");
+1
View File
@@ -145,6 +145,7 @@ async function typeSequence(page: Page, text: string): Promise<void> {
del: "Delete",
tab: "Tab",
home: "Home",
end: "End",
rightarrow: "ArrowRight",
};
+6 -6
View File
@@ -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
});
},
+2
View File
@@ -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
View File
@@ -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}
+5
View File
@@ -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" }} />
+2
View File
@@ -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 />
+3
View File
@@ -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" }} />
+3 -3
View File
@@ -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)
)
);
}
+4 -4
View File
@@ -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();
};
+7
View File
@@ -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}
+2 -2
View File
@@ -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"),
};
}
-24
View File
@@ -1,24 +0,0 @@
import { describe, it, expect } from "vitest";
import type { StyleSpecification } from "maplibre-gl";
import { undoMessages, redoMessages } from "./diffmessage";
const before = { version: 8, sources: {}, layers: [] } as StyleSpecification;
const after = {
version: 8,
sources: {},
layers: [{ id: "bg", type: "background" }],
} as StyleSpecification;
describe("diff messages", () => {
it("prefixes undo messages with 'Undo'", () => {
const messages = undoMessages(before, after);
expect(messages.length).toBeGreaterThan(0);
expect(messages.every((m) => m.startsWith("Undo "))).toBe(true);
});
it("prefixes redo messages with 'Redo'", () => {
const messages = redoMessages(before, after);
expect(messages.length).toBeGreaterThan(0);
expect(messages.every((m) => m.startsWith("Redo "))).toBe(true);
});
});
-33
View File
@@ -1,33 +0,0 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { downloadGlyphsMetadata, downloadSpriteMetadata } from "./metadata";
afterEach(() => vi.restoreAllMocks());
describe("downloadGlyphsMetadata", () => {
it("returns an empty list for an empty url", async () => {
expect(await downloadGlyphsMetadata("")).toEqual([]);
});
it("fetches the fontstacks list and de-duplicates it", async () => {
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true, json: async () => ["A", "A", "B"] })));
const fonts = await downloadGlyphsMetadata("https://example.com/{fontstack}/{range}.pbf");
expect(fonts.sort()).toEqual(["A", "B"]);
});
it("returns the default on a failed request", async () => {
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: false })));
expect(await downloadGlyphsMetadata("https://example.com/x/{fontstack}/{range}.pbf")).toEqual([]);
});
});
describe("downloadSpriteMetadata", () => {
it("returns the sprite icon names", async () => {
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true, json: async () => ({ airport: {}, park: {} }) })));
const icons = await downloadSpriteMetadata("https://example.com/sprite");
expect(icons.sort()).toEqual(["airport", "park"]);
});
it("returns an empty list for an empty base url", async () => {
expect(await downloadSpriteMetadata("")).toEqual([]);
});
});
-34
View File
@@ -1,34 +0,0 @@
import { describe, it, expect } from "vitest";
import { RevisionStore } from "./revisions";
const rev = (id: string) => ({ version: 8, id, sources: {}, layers: [] }) as any;
describe("RevisionStore", () => {
it("tracks latest/current as revisions are added", () => {
const store = new RevisionStore();
store.addRevision(rev("a"));
store.addRevision(rev("b"));
expect(store.latest.id).toBe("b");
expect(store.current.id).toBe("b");
});
it("undo and redo move through history", () => {
const store = new RevisionStore();
store.addRevision(rev("a"));
store.addRevision(rev("b"));
expect(store.undo().id).toBe("a");
expect(store.undo().id).toBe("a"); // clamped at start
expect(store.redo().id).toBe("b");
expect(store.redo().id).toBe("b"); // clamped at end
});
it("clears redo history when a new revision is added after undo", () => {
const store = new RevisionStore();
store.addRevision(rev("a"));
store.addRevision(rev("b"));
store.undo();
store.addRevision(rev("c"));
expect(store.latest.id).toBe("c");
expect(store.redo().id).toBe("c");
});
});
-23
View File
@@ -1,23 +0,0 @@
import { describe, it, expect } from "vitest";
import { addSource, changeSource, deleteSource } from "./source";
const style = { version: 8, id: "s", sources: { a: { type: "vector" } }, layers: [] } as any;
describe("source helpers", () => {
it("adds a source", () => {
const result = addSource(style, "b", { type: "geojson", data: {} } as any);
expect(result.sources.b).toEqual({ type: "geojson", data: {} });
expect(result.sources.a).toBeDefined();
});
it("changes an existing source", () => {
const result = changeSource(style, "a", { type: "raster" } as any);
expect(result.sources.a).toEqual({ type: "raster" });
});
it("deletes a source without mutating the input", () => {
const result = deleteSource(style, "a");
expect(result.sources.a).toBeUndefined();
expect(style.sources.a).toBeDefined();
});
});
+6 -6
View File
@@ -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)
)
);
+10 -11
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { StyleStore } from "./stylestore";
// StyleStore reads/writes window.localStorage; provide a minimal in-memory mock.
class LocalStorageMock {
private store: Record<string, string> = {};
get length() {
@@ -22,15 +22,14 @@ class LocalStorageMock {
this.store = {};
}
}
vi.stubGlobal("window", { localStorage: new LocalStorageMock() });
// Avoid network in loadDefaultStyle.
vi.mock("../urlopen", () => ({
loadStyleUrl: vi.fn(async () => ({ version: 8, id: "default", sources: {}, layers: [] })),
}));
import { StyleStore } from "./stylestore";
// 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;
@@ -47,7 +46,7 @@ describe("StyleStore", () => {
it("saves a style and reads it back as the latest", async () => {
const store = new StyleStore();
await store.save(style("abc"));
store.save(style("abc"));
// A fresh store discovers the persisted style ids.
const reopened = new StyleStore();
const latest = await reopened.getLatestStyle();
@@ -56,7 +55,7 @@ describe("StyleStore", () => {
it("purge removes all maputnik keys", async () => {
const store = new StyleStore();
await store.save(style("abc"));
store.save(style("abc"));
window.localStorage.setItem("unrelated", "keep");
store.purge();
expect(window.localStorage.getItem("maputnik:style:abc")).toBeNull();
+2 -2
View File
@@ -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 = () => {
-120
View File
@@ -1,120 +0,0 @@
import { describe, it, expect } from "vitest";
import type { StyleSpecification } from "maplibre-gl";
import style from "./style";
function baseStyle(overrides: Partial<StyleSpecification> = {}): StyleSpecification {
return { version: 8, sources: {}, layers: [], ...overrides } as StyleSpecification;
}
describe("ensureStyleValidity", () => {
it("adds an id and strips interactive from layers", () => {
const result = style.ensureStyleValidity(
baseStyle({
layers: [{ id: "l", type: "background", interactive: true } as any],
})
);
expect(result.id).toBeTruthy();
expect("interactive" in result.layers[0]).toBe(false);
});
it("keeps an existing id", () => {
const result = style.ensureStyleValidity(baseStyle({ id: "keep-me" } as any));
expect(result.id).toBe("keep-me");
});
});
describe("generateId", () => {
it("generates a non-empty string", () => {
expect(typeof style.generateId()).toBe("string");
expect(style.generateId().length).toBeGreaterThan(0);
});
});
describe("indexOfLayer", () => {
const layers = [{ id: "a" }, { id: "b" }] as any;
it("returns the index of a matching layer", () => {
expect(style.indexOfLayer(layers, "b")).toBe(1);
});
it("returns null when not found", () => {
expect(style.indexOfLayer(layers, "missing")).toBeNull();
});
});
describe("getAccessToken", () => {
it("reads the token from metadata", () => {
const s = baseStyle({ metadata: { "maputnik:openmaptiles_access_token": "abc" } } as any);
expect(style.getAccessToken("openmaptiles", s, {})).toBe("abc");
});
it("falls back to the bundled token only when allowed", () => {
const s = baseStyle();
expect(style.getAccessToken("openmaptiles", s, {})).toBeUndefined();
expect(style.getAccessToken("openmaptiles", s, { allowFallback: true })).toBeTruthy();
});
});
describe("replaceAccessTokens", () => {
it("replaces {key} in a source url and in glyphs", () => {
const s = baseStyle({
metadata: { "maputnik:openmaptiles_access_token": "TОKEN" } as any,
sources: { openmaptiles: { type: "vector", url: "https://api.maptiler.com/x?key={key}" } } as any,
glyphs: "https://api.maptiler.com/fonts/{fontstack}/{range}.pbf?key={key}",
});
const result = style.replaceAccessTokens(s);
expect((result.sources.openmaptiles as any).url).toContain("key=TОKEN");
expect(result.glyphs).toContain("key=TОKEN");
});
it("maps thunderforest transport/outdoors sources to the thunderforest token", () => {
const s = baseStyle({
metadata: { "maputnik:thunderforest_access_token": "TF" } as any,
sources: { thunderforest_transport: { type: "vector", url: "https://tile.thunderforest.com/x?apikey={key}" } } as any,
});
const result = style.replaceAccessTokens(s);
expect((result.sources.thunderforest_transport as any).url).toContain("TF");
});
it("appends an api_key query param for stadia sources", () => {
const s = baseStyle({
metadata: { "maputnik:stadia_access_token": "ST" } as any,
sources: { basemap: { type: "vector", url: "https://tiles.stadiamaps.com/data/x.json" } } as any,
});
const result = style.replaceAccessTokens(s);
expect((result.sources.basemap as any).url).toContain("api_key=ST");
});
it("uses the locationiq token for locationiq sources", () => {
const s = baseStyle({
metadata: { "maputnik:locationiq_access_token": "LIQ" } as any,
sources: { liq: { type: "vector", url: "https://tiles.locationiq.com/v3/x?key={key}" } } as any,
});
const result = style.replaceAccessTokens(s);
expect((result.sources.liq as any).url).toContain("LIQ");
});
it("leaves sources without a url or token untouched", () => {
const s = baseStyle({
sources: {
noUrl: { type: "geojson", data: {} } as any,
noToken: { type: "vector", url: "https://api.maptiler.com/x?key={key}" } as any,
},
});
const result = style.replaceAccessTokens(s);
expect((result.sources.noToken as any).url).toContain("{key}");
});
});
describe("stripAccessTokens", () => {
it("removes provider access tokens from metadata", () => {
const s = baseStyle({
metadata: {
"maputnik:openmaptiles_access_token": "a",
"maputnik:thunderforest_access_token": "b",
"maputnik:renderer": "mlgljs",
} as any,
});
const result = style.stripAccessTokens(s);
expect(result.metadata).not.toHaveProperty("maputnik:openmaptiles_access_token");
expect(result.metadata).not.toHaveProperty("maputnik:thunderforest_access_token");
expect((result.metadata as any)["maputnik:renderer"]).toBe("mlgljs");
});
});
+1 -11
View File
@@ -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
View File
@@ -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;
}
}