mirror of
https://github.com/maputnik/editor.git
synced 2026-09-15 16:57:41 +00:00
Compare commits
19 Commits
1a5c879c75
...
b09c629be4
| Author | SHA1 | Date | |
|---|---|---|---|
| b09c629be4 | |||
| 89be954485 | |||
| 83c099bef4 | |||
| 382ba69134 | |||
| dbe5d3916d | |||
| 373b57a6f4 | |||
| f2579e2a19 | |||
| aa9530274f | |||
| 0ba7b3704f | |||
| be11f75c9f | |||
| 03a41cf17a | |||
| 192df96a79 | |||
| 10d47c561b | |||
| c22f7404e7 | |||
| 36073b2b1f | |||
| ca66f47be0 | |||
| a5c47437ee | |||
| 91a6335d09 | |||
| acaa350f0f |
@@ -36,7 +36,7 @@ Then run the end-to-end tests (Playwright starts the dev server automatically):
|
|||||||
npm run test
|
npm run test
|
||||||
```
|
```
|
||||||
|
|
||||||
Run the unit and component tests with Vitest:
|
Run the unit tests with Vitest:
|
||||||
|
|
||||||
```
|
```
|
||||||
npm run test-unit
|
npm run test-unit
|
||||||
@@ -45,3 +45,106 @@ npm run test-unit
|
|||||||
## Pull Requests
|
## Pull Requests
|
||||||
|
|
||||||
- Pull requests should update `CHANGELOG.md` with a short description of the change.
|
- 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": []
|
||||||
|
}
|
||||||
+319
-74
@@ -25,7 +25,18 @@ describe("layer editor", () => {
|
|||||||
return id;
|
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 () => {
|
test("id", async () => {
|
||||||
const bgId = await createBackground();
|
const bgId = await createBackground();
|
||||||
@@ -78,7 +89,6 @@ describe("layer editor", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("the range slider adjusts min-zoom", async () => {
|
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.focus("min-zoom.input-range");
|
||||||
await when.typeKeys("{rightarrow}");
|
await when.typeKeys("{rightarrow}");
|
||||||
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
@@ -182,103 +192,285 @@ describe("layer editor", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("filter", () => {
|
describe("filter", () => {
|
||||||
test("compound filter", async () => {
|
let id: string;
|
||||||
const id = await when.modal.fillLayers({ type: "fill", layer: "example" });
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
id = await when.modal.fillLayers({ type: "fill", layer: "example" });
|
||||||
await when.addFilter();
|
await when.addFilter();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should add a filter item", async () => {
|
||||||
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
layers: [{ id, type: "fill", source: "example", filter: ["all", ["==", "name", ""]] }],
|
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 when.selectFilterOperator("!=");
|
||||||
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
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 when.addFilter();
|
||||||
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
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", () => {
|
describe("functions", () => {
|
||||||
test("convert a property to a zoom function and add a stop", async () => {
|
let id: string;
|
||||||
const id = await when.modal.fillLayers({ type: "circle", layer: "example" });
|
|
||||||
await when.makeZoomFunction("circle-radius");
|
describe("zoom function", () => {
|
||||||
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
beforeEach(async () => {
|
||||||
layers: [{ id, type: "circle", source: "example", paint: { "circle-radius": { stops: [[6, 5], [10, 5]] } } }],
|
id = await when.modal.fillLayers({ type: "circle", layer: "example" });
|
||||||
|
await when.makeZoomFunction("circle-radius");
|
||||||
});
|
});
|
||||||
|
|
||||||
await when.addFunctionStop("circle-radius");
|
test("should convert the property to a zoom function", async () => {
|
||||||
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
layers: [{ id, type: "circle", source: "example", paint: { "circle-radius": { stops: [[6, 5], [10, 5], [11, 5]] } } }],
|
layers: [
|
||||||
|
{ id, type: "circle", source: "example", paint: { "circle-radius": { stops: [[6, 5], [10, 5]] } } },
|
||||||
|
],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Deleting the first stop leaves the rest.
|
test("should add a stop", async () => {
|
||||||
await when.deleteFunctionStop("circle-radius");
|
await when.addFunctionStop("circle-radius");
|
||||||
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
layers: [{ id, type: "circle", source: "example", paint: { "circle-radius": { stops: [[10, 5], [11, 5]] } } }],
|
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 () => {
|
describe("data function", () => {
|
||||||
const id = await when.modal.fillLayers({ type: "circle", layer: "example" });
|
beforeEach(async () => {
|
||||||
// The property needs a value before it can be turned into a data function.
|
id = await when.modal.fillLayers({ type: "circle", layer: "example" });
|
||||||
await when.setValue("spec-field-input:circle-blur", "1");
|
await when.setValue("spec-field-input:circle-blur", "1");
|
||||||
await when.makeDataFunction("circle-blur");
|
await when.makeDataFunction("circle-blur");
|
||||||
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
|
||||||
layers: [
|
|
||||||
{
|
|
||||||
id,
|
|
||||||
type: "circle",
|
|
||||||
source: "example",
|
|
||||||
paint: {
|
|
||||||
"circle-blur": {
|
|
||||||
property: "",
|
|
||||||
type: "exponential",
|
|
||||||
stops: [[{ zoom: 6, value: 0 }, 1], [{ zoom: 10, value: 0 }, 1]],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await when.addFunctionStop("circle-blur");
|
test("should convert the property to a data function", async () => {
|
||||||
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
layers: [
|
layers: [
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
type: "circle",
|
type: "circle",
|
||||||
source: "example",
|
source: "example",
|
||||||
paint: {
|
paint: {
|
||||||
"circle-blur": {
|
"circle-blur": {
|
||||||
property: "",
|
property: "",
|
||||||
type: "exponential",
|
type: "exponential",
|
||||||
stops: [[{ zoom: 6, value: 0 }, 1], [{ zoom: 10, value: 0 }, 1], [{ zoom: 11, value: 0 }, 1]],
|
stops: [[{ zoom: 6, value: 0 }, 1], [{ zoom: 10, value: 0 }, 1]],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
],
|
||||||
],
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await when.deleteFunctionStop("circle-blur");
|
test("should add a stop", async () => {
|
||||||
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
await when.addFunctionStop("circle-blur");
|
||||||
layers: [
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
{
|
layers: [
|
||||||
id,
|
{
|
||||||
type: "circle",
|
id,
|
||||||
source: "example",
|
paint: {
|
||||||
paint: {
|
"circle-blur": {
|
||||||
"circle-blur": {
|
stops: [[{ zoom: 6, value: 0 }, 1], [{ zoom: 10, value: 0 }, 1], [{ zoom: 11, value: 0 }, 1]],
|
||||||
property: "",
|
},
|
||||||
type: "exponential",
|
|
||||||
stops: [[{ 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", () => {
|
describe("paint", () => {
|
||||||
test.skip("expand/collapse", () => {});
|
let id: string;
|
||||||
test.skip("color", () => {});
|
|
||||||
test.skip("pattern", () => {});
|
beforeEach(async () => {
|
||||||
test.skip("opacity", () => {});
|
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", () => {
|
describe("json-editor", () => {
|
||||||
@@ -319,8 +544,28 @@ describe("layer editor", () => {
|
|||||||
await then(get.element(".cm-lint-marker-error")).shouldExist();
|
await then(get.element(".cm-lint-marker-error")).shouldExist();
|
||||||
});
|
});
|
||||||
|
|
||||||
test.skip("expand/collapse", () => {});
|
test("expand/collapse", async () => {
|
||||||
test.skip("modify", () => {});
|
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 () => {
|
test("parse error", async () => {
|
||||||
const bgId = await createBackground();
|
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", () => {
|
describe("fill", () => {
|
||||||
@@ -108,8 +116,13 @@ describe("layers list", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO: Change source
|
test("change source", async () => {
|
||||||
test.skip("change source", () => {});
|
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", () => {
|
describe("line", () => {
|
||||||
|
|||||||
+117
-7
@@ -38,6 +38,7 @@ export class MaputnikDriver {
|
|||||||
"example-style-with-fonts.json",
|
"example-style-with-fonts.json",
|
||||||
"example-style-with-zoom-7-and-center-0-51.json",
|
"example-style-with-zoom-7-and-center-0-51.json",
|
||||||
"example-style-with-zoom-5-and-center-50-50.json",
|
"example-style-with-zoom-5-and-center-50-50.json",
|
||||||
|
"access-token-style.json",
|
||||||
];
|
];
|
||||||
for (const fixture of styleFixtures) {
|
for (const fixture of styleFixtures) {
|
||||||
await this.helper.given.interceptAndMockResponse({
|
await this.helper.given.interceptAndMockResponse({
|
||||||
@@ -71,6 +72,7 @@ export class MaputnikDriver {
|
|||||||
| "rectangles"
|
| "rectangles"
|
||||||
| "font"
|
| "font"
|
||||||
| "zoom_7_center_0_51"
|
| "zoom_7_center_0_51"
|
||||||
|
| "access_tokens"
|
||||||
| "",
|
| "",
|
||||||
zoom?: number
|
zoom?: number
|
||||||
) => {
|
) => {
|
||||||
@@ -82,6 +84,7 @@ export class MaputnikDriver {
|
|||||||
rectangles: "rectangles-style.json",
|
rectangles: "rectangles-style.json",
|
||||||
font: "example-style-with-fonts.json",
|
font: "example-style-with-fonts.json",
|
||||||
zoom_7_center_0_51: "example-style-with-zoom-7-and-center-0-51.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);
|
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();
|
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) => {
|
appendTextInJsonEditor: async (text: string) => {
|
||||||
await this.helper.get.element(".cm-line").first().click();
|
await this.helper.get.element(".cm-line").first().click();
|
||||||
// Move to the very start of the document so the inserted text breaks the
|
// 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 });
|
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 () => {
|
addFilter: async () => {
|
||||||
const button = this.helper.get.elementByTestId("layer-filter-button");
|
const button = this.helper.get.elementByTestId("layer-filter-button");
|
||||||
await button.scrollIntoViewIfNeeded();
|
await button.scrollIntoViewIfNeeded();
|
||||||
@@ -190,8 +270,29 @@ export class MaputnikDriver {
|
|||||||
await this.helper.get.element(".maputnik-filter-editor-operator select").first().selectOption(value);
|
await this.helper.get.element(".maputnik-filter-editor-operator select").first().selectOption(value);
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteFirstActiveSource: async () => {
|
/** Chooses how the filter items combine: all / none / any. */
|
||||||
await this.helper.get.element(".maputnik-active-source-type-editor-header-delete").first().click();
|
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) => {
|
setColorValue: async (fieldName: string, value: string) => {
|
||||||
@@ -199,13 +300,22 @@ export class MaputnikDriver {
|
|||||||
await input.fill(value);
|
await input.fill(value);
|
||||||
},
|
},
|
||||||
|
|
||||||
exportCreateHtml: async () => {
|
/** Sets a plain string spec field (e.g. a pattern), which has no dedicated input test id. */
|
||||||
await this.helper.get.element(".maputnik-modal-export-buttons button").last().click();
|
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();
|
* Appends text to the end of the JSON editor line holding `lineText`.
|
||||||
await this.helper.get.element(".maputnik-modal-export-buttons button").first().click();
|
* 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"),
|
waitForExampleFileResponse: () => this.helper.when.waitForResponse("example-style.json"),
|
||||||
|
|||||||
@@ -34,5 +34,45 @@ export class ModalDriver {
|
|||||||
close: async (key: string) => {
|
close: async (key: string) => {
|
||||||
await this.helper.when.click(key + ".close-modal");
|
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
@@ -66,11 +66,11 @@ describe("modals", () => {
|
|||||||
|
|
||||||
test("download HTML and save the style", async () => {
|
test("download HTML and save the style", async () => {
|
||||||
// Generate the standalone HTML export (triggers a file download).
|
// Generate the standalone HTML export (triggers a file download).
|
||||||
await when.exportCreateHtml();
|
await when.modal.exportCreateHtml();
|
||||||
await then(get.elementByTestId("modal:export")).shouldExist();
|
await then(get.elementByTestId("modal:export")).shouldExist();
|
||||||
|
|
||||||
// Saving the style closes the export modal.
|
// Saving the style closes the export modal.
|
||||||
await when.exportSaveStyle();
|
await when.modal.exportSaveStyle();
|
||||||
await then(get.elementByTestId("modal:export")).shouldNotExist();
|
await then(get.elementByTestId("modal:export")).shouldNotExist();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -82,17 +82,26 @@ describe("modals", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("active sources are listed and can be deleted", async () => {
|
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.setStyle("both");
|
||||||
await when.click("nav:sources");
|
await when.click("nav:sources");
|
||||||
const before = Object.keys(get.fixture("geojson-raster-style.json").sources).length;
|
const before = Object.keys(get.fixture("geojson-raster-style.json").sources).length;
|
||||||
await when.deleteFirstActiveSource();
|
await when.modal.deleteFirstActiveSource();
|
||||||
await then(
|
await then(
|
||||||
get.styleFromLocalStorage().then((style) => Object.keys(style.sources).length)
|
get.styleFromLocalStorage().then((style) => Object.keys(style.sources).length)
|
||||||
).shouldEqual(before - 1);
|
).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 () => {
|
test("add new source", async () => {
|
||||||
const sourceId = "n1z2v3r";
|
const sourceId = "n1z2v3r";
|
||||||
@@ -139,6 +148,108 @@ describe("modals", () => {
|
|||||||
sources: { [sourceId]: { tileSize: 128 } },
|
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", () => {
|
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 () => {
|
test("style projection mercator", async () => {
|
||||||
await when.select("modal:settings.projection", "mercator");
|
await when.select("modal:settings.projection", "mercator");
|
||||||
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
@@ -328,10 +477,6 @@ describe("modals", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("sources placeholder", () => {
|
|
||||||
test.skip("toggle", () => {});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("global state", () => {
|
describe("global state", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await when.click("nav:global-state");
|
await when.click("nav:global-state");
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ async function typeSequence(page: Page, text: string): Promise<void> {
|
|||||||
del: "Delete",
|
del: "Delete",
|
||||||
tab: "Tab",
|
tab: "Tab",
|
||||||
home: "Home",
|
home: "Home",
|
||||||
|
end: "End",
|
||||||
rightarrow: "ArrowRight",
|
rightarrow: "ArrowRight",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import ModalDebug from "./modals/ModalDebug";
|
|||||||
import ModalGlobalState from "./modals/ModalGlobalState";
|
import ModalGlobalState from "./modals/ModalGlobalState";
|
||||||
|
|
||||||
import {downloadGlyphsMetadata, downloadSpriteMetadata} from "../libs/metadata";
|
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 { undoMessages, redoMessages } from "../libs/diffmessage";
|
||||||
import { createStyleStore, type IStyleStore } from "../libs/store/style-store-factory";
|
import { createStyleStore, type IStyleStore } from "../libs/store/style-store-factory";
|
||||||
import { RevisionStore } from "../libs/revisions";
|
import { RevisionStore } from "../libs/revisions";
|
||||||
@@ -48,19 +48,19 @@ function setFetchAccessToken(url: string, mapStyle: StyleSpecification) {
|
|||||||
const matchesThunderforest = url.match(/\.thunderforest\.com/);
|
const matchesThunderforest = url.match(/\.thunderforest\.com/);
|
||||||
const matchesLocationIQ = url.match(/\.locationiq\.com/);
|
const matchesLocationIQ = url.match(/\.locationiq\.com/);
|
||||||
if (matchesTilehosting || matchesMaptiler) {
|
if (matchesTilehosting || matchesMaptiler) {
|
||||||
const accessToken = style.getAccessToken("openmaptiles", mapStyle, {allowFallback: true});
|
const accessToken = getAccessToken("openmaptiles", mapStyle, {allowFallback: true});
|
||||||
if (accessToken) {
|
if (accessToken) {
|
||||||
return url.replace("{key}", accessToken);
|
return url.replace("{key}", accessToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (matchesThunderforest) {
|
else if (matchesThunderforest) {
|
||||||
const accessToken = style.getAccessToken("thunderforest", mapStyle, {allowFallback: true});
|
const accessToken = getAccessToken("thunderforest", mapStyle, {allowFallback: true});
|
||||||
if (accessToken) {
|
if (accessToken) {
|
||||||
return url.replace("{key}", accessToken);
|
return url.replace("{key}", accessToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (matchesLocationIQ) {
|
else if (matchesLocationIQ) {
|
||||||
const accessToken = style.getAccessToken("locationiq", mapStyle, {allowFallback: true});
|
const accessToken = getAccessToken("locationiq", mapStyle, {allowFallback: true});
|
||||||
if (accessToken) {
|
if (accessToken) {
|
||||||
return url.replace("{key}", accessToken);
|
return url.replace("{key}", accessToken);
|
||||||
}
|
}
|
||||||
@@ -137,7 +137,7 @@ export default class App extends React.Component<any, AppState> {
|
|||||||
this.state = {
|
this.state = {
|
||||||
errors: [],
|
errors: [],
|
||||||
infos: [],
|
infos: [],
|
||||||
mapStyle: style.emptyStyle,
|
mapStyle: emptyStyle,
|
||||||
selectedLayerIndex: 0,
|
selectedLayerIndex: 0,
|
||||||
sources: {},
|
sources: {},
|
||||||
vectorLayers: {},
|
vectorLayers: {},
|
||||||
@@ -698,7 +698,7 @@ export default class App extends React.Component<any, AppState> {
|
|||||||
mapStyle: (dirtyMapStyle || mapStyle),
|
mapStyle: (dirtyMapStyle || mapStyle),
|
||||||
mapView: this.state.mapView,
|
mapView: this.state.mapView,
|
||||||
replaceAccessTokens: (mapStyle: StyleSpecification) => {
|
replaceAccessTokens: (mapStyle: StyleSpecification) => {
|
||||||
return style.replaceAccessTokens(mapStyle, {
|
return replaceAccessTokens(mapStyle, {
|
||||||
allowFallback: true
|
allowFallback: true
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -215,6 +215,7 @@ class FilterEditorInternal extends React.Component<FilterEditorInternalProps, Fi
|
|||||||
onClick={this.makeExpression}
|
onClick={this.makeExpression}
|
||||||
title={t("Convert to expression")}
|
title={t("Convert to expression")}
|
||||||
className="maputnik-make-zoom-function"
|
className="maputnik-make-zoom-function"
|
||||||
|
data-wd-key="filter-convert-to-expression"
|
||||||
>
|
>
|
||||||
<TbMathFunction />
|
<TbMathFunction />
|
||||||
</InputButton>
|
</InputButton>
|
||||||
@@ -248,6 +249,7 @@ class FilterEditorInternal extends React.Component<FilterEditorInternalProps, Fi
|
|||||||
fieldSpec={fieldSpec}
|
fieldSpec={fieldSpec}
|
||||||
label={t("Filter")}
|
label={t("Filter")}
|
||||||
action={actions}
|
action={actions}
|
||||||
|
data-wd-key="filter-combining-operator"
|
||||||
>
|
>
|
||||||
<InputSelect
|
<InputSelect
|
||||||
value={combiningOp}
|
value={combiningOp}
|
||||||
|
|||||||
@@ -230,6 +230,7 @@ class LayerEditorInternal extends React.Component<LayerEditorInternalProps, Laye
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{this.props.layer.type !== "background" && <FieldSource
|
{this.props.layer.type !== "background" && <FieldSource
|
||||||
|
wdKey="layer-editor.layer-source"
|
||||||
error={errorData.source}
|
error={errorData.source}
|
||||||
sourceIds={Object.keys(this.props.sources!)}
|
sourceIds={Object.keys(this.props.sources!)}
|
||||||
value={this.props.layer.source}
|
value={this.props.layer.source}
|
||||||
|
|||||||
@@ -289,6 +289,7 @@ class DataPropertyInternal extends React.Component<DataPropertyInternalProps, Da
|
|||||||
<Block
|
<Block
|
||||||
label={t("Function")}
|
label={t("Function")}
|
||||||
key="function"
|
key="function"
|
||||||
|
data-wd-key="function-type"
|
||||||
>
|
>
|
||||||
<div className="maputnik-data-spec-property-input">
|
<div className="maputnik-data-spec-property-input">
|
||||||
<InputSelect
|
<InputSelect
|
||||||
@@ -303,6 +304,7 @@ class DataPropertyInternal extends React.Component<DataPropertyInternalProps, Da
|
|||||||
<Block
|
<Block
|
||||||
label={t("Base")}
|
label={t("Base")}
|
||||||
key="base"
|
key="base"
|
||||||
|
data-wd-key="function-base"
|
||||||
>
|
>
|
||||||
<div className="maputnik-data-spec-property-input">
|
<div className="maputnik-data-spec-property-input">
|
||||||
<InputSpec
|
<InputSpec
|
||||||
@@ -317,6 +319,7 @@ class DataPropertyInternal extends React.Component<DataPropertyInternalProps, Da
|
|||||||
<Block
|
<Block
|
||||||
label={"Property"}
|
label={"Property"}
|
||||||
key="property"
|
key="property"
|
||||||
|
data-wd-key="function-property"
|
||||||
>
|
>
|
||||||
<div className="maputnik-data-spec-property-input">
|
<div className="maputnik-data-spec-property-input">
|
||||||
<InputString
|
<InputString
|
||||||
@@ -330,6 +333,7 @@ class DataPropertyInternal extends React.Component<DataPropertyInternalProps, Da
|
|||||||
<Block
|
<Block
|
||||||
label={t("Default")}
|
label={t("Default")}
|
||||||
key="default"
|
key="default"
|
||||||
|
data-wd-key="function-default"
|
||||||
>
|
>
|
||||||
<InputSpec
|
<InputSpec
|
||||||
fieldName={this.props.fieldName}
|
fieldName={this.props.fieldName}
|
||||||
@@ -368,6 +372,7 @@ class DataPropertyInternal extends React.Component<DataPropertyInternalProps, Da
|
|||||||
}
|
}
|
||||||
<InputButton
|
<InputButton
|
||||||
className="maputnik-add-stop"
|
className="maputnik-add-stop"
|
||||||
|
data-wd-key="convert-to-expression"
|
||||||
onClick={this.props.onExpressionClick?.bind(this)}
|
onClick={this.props.onExpressionClick?.bind(this)}
|
||||||
>
|
>
|
||||||
<TbMathFunction style={{ verticalAlign: "text-bottom" }} />
|
<TbMathFunction style={{ verticalAlign: "text-bottom" }} />
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ class ExpressionPropertyInternal extends React.Component<ExpressionPropertyInter
|
|||||||
onClick={this.props.onUndo}
|
onClick={this.props.onUndo}
|
||||||
disabled={undoDisabled}
|
disabled={undoDisabled}
|
||||||
className="maputnik-delete-stop"
|
className="maputnik-delete-stop"
|
||||||
|
data-wd-key="undo-expression"
|
||||||
title={t("Revert from expression")}
|
title={t("Revert from expression")}
|
||||||
>
|
>
|
||||||
<MdUndo />
|
<MdUndo />
|
||||||
@@ -59,6 +60,7 @@ class ExpressionPropertyInternal extends React.Component<ExpressionPropertyInter
|
|||||||
key="delete_action"
|
key="delete_action"
|
||||||
onClick={this.props.onDelete}
|
onClick={this.props.onDelete}
|
||||||
className="maputnik-delete-stop"
|
className="maputnik-delete-stop"
|
||||||
|
data-wd-key="delete-expression"
|
||||||
title={t("Delete expression")}
|
title={t("Delete expression")}
|
||||||
>
|
>
|
||||||
<MdDelete />
|
<MdDelete />
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ class ZoomPropertyInternal extends React.Component<ZoomPropertyInternalProps, Zo
|
|||||||
<div className="maputnik-data-fieldset-inner">
|
<div className="maputnik-data-fieldset-inner">
|
||||||
<Block
|
<Block
|
||||||
label={t("Function")}
|
label={t("Function")}
|
||||||
|
data-wd-key="function-type"
|
||||||
>
|
>
|
||||||
<div className="maputnik-data-spec-property-input">
|
<div className="maputnik-data-spec-property-input">
|
||||||
<InputSelect
|
<InputSelect
|
||||||
@@ -206,6 +207,7 @@ class ZoomPropertyInternal extends React.Component<ZoomPropertyInternalProps, Zo
|
|||||||
</Block>
|
</Block>
|
||||||
<Block
|
<Block
|
||||||
label={t("Base")}
|
label={t("Base")}
|
||||||
|
data-wd-key="function-base"
|
||||||
>
|
>
|
||||||
<div className="maputnik-data-spec-property-input">
|
<div className="maputnik-data-spec-property-input">
|
||||||
<InputSpec
|
<InputSpec
|
||||||
@@ -240,6 +242,7 @@ class ZoomPropertyInternal extends React.Component<ZoomPropertyInternalProps, Zo
|
|||||||
</InputButton>
|
</InputButton>
|
||||||
<InputButton
|
<InputButton
|
||||||
className="maputnik-add-stop"
|
className="maputnik-add-stop"
|
||||||
|
data-wd-key="convert-to-expression"
|
||||||
onClick={this.props.onExpressionClick?.bind(this)}
|
onClick={this.props.onExpressionClick?.bind(this)}
|
||||||
>
|
>
|
||||||
<TbMathFunction style={{ verticalAlign: "text-bottom" }} />
|
<TbMathFunction style={{ verticalAlign: "text-bottom" }} />
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {type WithTranslation, withTranslation} from "react-i18next";
|
|||||||
import FieldString from "../FieldString";
|
import FieldString from "../FieldString";
|
||||||
import InputButton from "../InputButton";
|
import InputButton from "../InputButton";
|
||||||
import Modal from "./Modal";
|
import Modal from "./Modal";
|
||||||
import style from "../../libs/style";
|
import {replaceAccessTokens, stripAccessTokens} from "../../libs/style";
|
||||||
import fieldSpecAdditional from "../../libs/field-spec-additional";
|
import fieldSpecAdditional from "../../libs/field-spec-additional";
|
||||||
import type {OnStyleChangedCallback, StyleSpecificationWithId} from "../../libs/definitions";
|
import type {OnStyleChangedCallback, StyleSpecificationWithId} from "../../libs/definitions";
|
||||||
|
|
||||||
@@ -32,8 +32,8 @@ class ModalExportInternal extends React.Component<ModalExportInternalProps> {
|
|||||||
|
|
||||||
tokenizedStyle() {
|
tokenizedStyle() {
|
||||||
return format(
|
return format(
|
||||||
style.stripAccessTokens(
|
stripAccessTokens(
|
||||||
style.replaceAccessTokens(this.props.mapStyle)
|
replaceAccessTokens(this.props.mapStyle)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import Modal from "./Modal";
|
|||||||
import InputButton from "../InputButton";
|
import InputButton from "../InputButton";
|
||||||
import InputUrl from "../InputUrl";
|
import InputUrl from "../InputUrl";
|
||||||
|
|
||||||
import style from "../../libs/style";
|
import { ensureStyleValidity } from "../../libs/style";
|
||||||
import publicStyles from "../../config/styles.json";
|
import publicStyles from "../../config/styles.json";
|
||||||
|
|
||||||
type PublicStyleProps = {
|
type PublicStyleProps = {
|
||||||
@@ -109,7 +109,7 @@ class ModalOpenInternal extends React.Component<ModalOpenInternalProps, ModalOpe
|
|||||||
activeRequestUrl: null
|
activeRequestUrl: null
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapStyle = style.ensureStyleValidity(body);
|
const mapStyle = ensureStyleValidity(body);
|
||||||
console.log("Loaded style ", mapStyle.id);
|
console.log("Loaded style ", mapStyle.id);
|
||||||
this.props.onStyleOpen(mapStyle);
|
this.props.onStyleOpen(mapStyle);
|
||||||
this.onOpenToggle();
|
this.onOpenToggle();
|
||||||
@@ -165,7 +165,7 @@ class ModalOpenInternal extends React.Component<ModalOpenInternalProps, ModalOpe
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
mapStyle = style.ensureStyleValidity(mapStyle);
|
mapStyle = ensureStyleValidity(mapStyle);
|
||||||
|
|
||||||
this.props.onStyleOpen(mapStyle, fileHandle);
|
this.props.onStyleOpen(mapStyle, fileHandle);
|
||||||
this.onOpenToggle();
|
this.onOpenToggle();
|
||||||
@@ -193,7 +193,7 @@ class ModalOpenInternal extends React.Component<ModalOpenInternalProps, ModalOpe
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
mapStyle = style.ensureStyleValidity(mapStyle);
|
mapStyle = ensureStyleValidity(mapStyle);
|
||||||
this.props.onStyleOpen(mapStyle);
|
this.props.onStyleOpen(mapStyle);
|
||||||
this.onOpenToggle();
|
this.onOpenToggle();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -206,6 +206,7 @@ class ModalSettingsInternal extends React.Component<ModalSettingsInternalProps>
|
|||||||
|
|
||||||
<FieldNumber
|
<FieldNumber
|
||||||
label={t("Zoom")}
|
label={t("Zoom")}
|
||||||
|
data-wd-key="modal:settings.zoom"
|
||||||
fieldSpec={latest.$root.zoom}
|
fieldSpec={latest.$root.zoom}
|
||||||
value={mapStyle.zoom}
|
value={mapStyle.zoom}
|
||||||
default={0}
|
default={0}
|
||||||
@@ -214,6 +215,7 @@ class ModalSettingsInternal extends React.Component<ModalSettingsInternalProps>
|
|||||||
|
|
||||||
<FieldNumber
|
<FieldNumber
|
||||||
label={t("Bearing")}
|
label={t("Bearing")}
|
||||||
|
data-wd-key="modal:settings.bearing"
|
||||||
fieldSpec={latest.$root.bearing}
|
fieldSpec={latest.$root.bearing}
|
||||||
value={mapStyle.bearing}
|
value={mapStyle.bearing}
|
||||||
default={latest.$root.bearing.default}
|
default={latest.$root.bearing.default}
|
||||||
@@ -222,6 +224,7 @@ class ModalSettingsInternal extends React.Component<ModalSettingsInternalProps>
|
|||||||
|
|
||||||
<FieldNumber
|
<FieldNumber
|
||||||
label={t("Pitch")}
|
label={t("Pitch")}
|
||||||
|
data-wd-key="modal:settings.pitch"
|
||||||
fieldSpec={latest.$root.pitch}
|
fieldSpec={latest.$root.pitch}
|
||||||
value={mapStyle.pitch}
|
value={mapStyle.pitch}
|
||||||
default={latest.$root.pitch.default}
|
default={latest.$root.pitch.default}
|
||||||
@@ -248,6 +251,7 @@ class ModalSettingsInternal extends React.Component<ModalSettingsInternalProps>
|
|||||||
|
|
||||||
<FieldNumber
|
<FieldNumber
|
||||||
label={t("Light intensity")}
|
label={t("Light intensity")}
|
||||||
|
data-wd-key="modal:settings.light-intensity"
|
||||||
fieldSpec={latest.light.intensity}
|
fieldSpec={latest.light.intensity}
|
||||||
value={light.intensity as number}
|
value={light.intensity as number}
|
||||||
default={latest.light.intensity.default}
|
default={latest.light.intensity.default}
|
||||||
@@ -274,6 +278,7 @@ class ModalSettingsInternal extends React.Component<ModalSettingsInternalProps>
|
|||||||
|
|
||||||
<FieldNumber
|
<FieldNumber
|
||||||
label={t("Terrain exaggeration")}
|
label={t("Terrain exaggeration")}
|
||||||
|
data-wd-key="modal:settings.terrain-exaggeration"
|
||||||
fieldSpec={latest.terrain.exaggeration}
|
fieldSpec={latest.terrain.exaggeration}
|
||||||
value={terrain.exaggeration}
|
value={terrain.exaggeration}
|
||||||
default={latest.terrain.exaggeration.default}
|
default={latest.terrain.exaggeration.default}
|
||||||
@@ -282,6 +287,7 @@ class ModalSettingsInternal extends React.Component<ModalSettingsInternalProps>
|
|||||||
|
|
||||||
<FieldNumber
|
<FieldNumber
|
||||||
label={t("Transition delay")}
|
label={t("Transition delay")}
|
||||||
|
data-wd-key="modal:settings.transition-delay"
|
||||||
fieldSpec={latest.transition.delay}
|
fieldSpec={latest.transition.delay}
|
||||||
value={transition.delay}
|
value={transition.delay}
|
||||||
default={latest.transition.delay.default}
|
default={latest.transition.delay.default}
|
||||||
@@ -290,6 +296,7 @@ class ModalSettingsInternal extends React.Component<ModalSettingsInternalProps>
|
|||||||
|
|
||||||
<FieldNumber
|
<FieldNumber
|
||||||
label={t("Transition duration")}
|
label={t("Transition duration")}
|
||||||
|
data-wd-key="modal:settings.transition-duration"
|
||||||
fieldSpec={latest.transition.duration}
|
fieldSpec={latest.transition.duration}
|
||||||
value={transition.duration}
|
value={transition.duration}
|
||||||
default={latest.transition.duration.default}
|
default={latest.transition.duration.default}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import FieldString from "../FieldString";
|
|||||||
import FieldSelect from "../FieldSelect";
|
import FieldSelect from "../FieldSelect";
|
||||||
import ModalSourcesTypeEditor, { type EditorMode } from "./ModalSourcesTypeEditor";
|
import ModalSourcesTypeEditor, { type EditorMode } from "./ModalSourcesTypeEditor";
|
||||||
|
|
||||||
import style from "../../libs/style";
|
import { generateId } from "../../libs/style";
|
||||||
import { deleteSource, addSource, changeSource } from "../../libs/source";
|
import { deleteSource, addSource, changeSource } from "../../libs/source";
|
||||||
import publicSources from "../../config/tilesets.json";
|
import publicSources from "../../config/tilesets.json";
|
||||||
import { type OnStyleChangedCallback, type StyleSpecificationWithId } from "../../libs/definitions";
|
import { type OnStyleChangedCallback, type StyleSpecificationWithId } from "../../libs/definitions";
|
||||||
@@ -121,7 +121,7 @@ class AddSource extends React.Component<AddSourceProps, AddSourceState> {
|
|||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
mode: "tilejson_vector",
|
mode: "tilejson_vector",
|
||||||
sourceId: style.generateId(),
|
sourceId: generateId(),
|
||||||
source: this.defaultSource("tilejson_vector"),
|
source: this.defaultSource("tilejson_vector"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import style from "../style";
|
import {emptyStyle, ensureStyleValidity, replaceAccessTokens, stripAccessTokens} from "../style";
|
||||||
import {format} from "@maplibre/maplibre-gl-style-spec";
|
import {format} from "@maplibre/maplibre-gl-style-spec";
|
||||||
import ReconnectingWebSocket from "reconnecting-websocket";
|
import ReconnectingWebSocket from "reconnecting-websocket";
|
||||||
import type {IStyleStore, OnStyleChangedCallback, StyleSpecificationWithId} from "../definitions";
|
import type {IStyleStore, OnStyleChangedCallback, StyleSpecificationWithId} from "../definitions";
|
||||||
@@ -40,13 +40,13 @@ export class ApiStyleStore implements IStyleStore {
|
|||||||
connection.onmessage = e => {
|
connection.onmessage = e => {
|
||||||
if(!e.data) return;
|
if(!e.data) return;
|
||||||
console.log("Received style update from API");
|
console.log("Received style update from API");
|
||||||
let parsedStyle = style.emptyStyle;
|
let parsedStyle = emptyStyle;
|
||||||
try {
|
try {
|
||||||
parsedStyle = JSON.parse(e.data);
|
parsedStyle = JSON.parse(e.data);
|
||||||
} catch(err) {
|
} catch(err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
}
|
}
|
||||||
const updatedStyle = style.ensureStyleValidity(parsedStyle);
|
const updatedStyle = ensureStyleValidity(parsedStyle);
|
||||||
this.onLocalStyleChange(updatedStyle);
|
this.onLocalStyleChange(updatedStyle);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -57,7 +57,7 @@ export class ApiStyleStore implements IStyleStore {
|
|||||||
mode: "cors",
|
mode: "cors",
|
||||||
});
|
});
|
||||||
const body = await response.json();
|
const body = await response.json();
|
||||||
return style.ensureStyleValidity(body);
|
return ensureStyleValidity(body);
|
||||||
} else {
|
} else {
|
||||||
throw new Error("No latest style available. You need to init the api backend first.");
|
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 current style replacing previous version
|
||||||
save(mapStyle: StyleSpecificationWithId) {
|
save(mapStyle: StyleSpecificationWithId) {
|
||||||
const styleJSON = format(
|
const styleJSON = format(
|
||||||
style.stripAccessTokens(
|
stripAccessTokens(
|
||||||
style.replaceAccessTokens(mapStyle)
|
replaceAccessTokens(mapStyle)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
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 {
|
class LocalStorageMock {
|
||||||
private store: Record<string, string> = {};
|
private store: Record<string, string> = {};
|
||||||
get length() {
|
get length() {
|
||||||
@@ -22,15 +22,14 @@ class LocalStorageMock {
|
|||||||
this.store = {};
|
this.store = {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
vi.stubGlobal("window", { localStorage: new LocalStorageMock() });
|
vi.stubGlobal("window", { localStorage: new LocalStorageMock() });
|
||||||
|
// loadDefaultStyle fetches the default style over the network; serve it locally.
|
||||||
// Avoid network in loadDefaultStyle.
|
vi.stubGlobal(
|
||||||
vi.mock("../urlopen", () => ({
|
"fetch",
|
||||||
loadStyleUrl: vi.fn(async () => ({ version: 8, id: "default", sources: {}, layers: [] })),
|
vi.fn(async () => ({
|
||||||
}));
|
json: async () => ({ version: 8, id: "default", sources: {}, layers: [] }),
|
||||||
|
}))
|
||||||
import { StyleStore } from "./stylestore";
|
);
|
||||||
|
|
||||||
const style = (id: string) => ({ version: 8, id, sources: {}, layers: [] }) as any;
|
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 () => {
|
it("saves a style and reads it back as the latest", async () => {
|
||||||
const store = new StyleStore();
|
const store = new StyleStore();
|
||||||
await store.save(style("abc"));
|
store.save(style("abc"));
|
||||||
// A fresh store discovers the persisted style ids.
|
// A fresh store discovers the persisted style ids.
|
||||||
const reopened = new StyleStore();
|
const reopened = new StyleStore();
|
||||||
const latest = await reopened.getLatestStyle();
|
const latest = await reopened.getLatestStyle();
|
||||||
@@ -56,7 +55,7 @@ describe("StyleStore", () => {
|
|||||||
|
|
||||||
it("purge removes all maputnik keys", async () => {
|
it("purge removes all maputnik keys", async () => {
|
||||||
const store = new StyleStore();
|
const store = new StyleStore();
|
||||||
await store.save(style("abc"));
|
store.save(style("abc"));
|
||||||
window.localStorage.setItem("unrelated", "keep");
|
window.localStorage.setItem("unrelated", "keep");
|
||||||
store.purge();
|
store.purge();
|
||||||
expect(window.localStorage.getItem("maputnik:style:abc")).toBeNull();
|
expect(window.localStorage.getItem("maputnik:style:abc")).toBeNull();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import style from "../style";
|
import {ensureStyleValidity} from "../style";
|
||||||
import {loadStyleUrl} from "../urlopen";
|
import {loadStyleUrl} from "../urlopen";
|
||||||
import publicSources from "../../config/styles.json";
|
import publicSources from "../../config/styles.json";
|
||||||
import type {IStyleStore, StyleSpecificationWithId} from "../definitions";
|
import type {IStyleStore, StyleSpecificationWithId} from "../definitions";
|
||||||
@@ -89,7 +89,7 @@ export class StyleStore implements IStyleStore {
|
|||||||
|
|
||||||
// Save current style replacing previous version
|
// Save current style replacing previous version
|
||||||
save(mapStyle: StyleSpecificationWithId) {
|
save(mapStyle: StyleSpecificationWithId) {
|
||||||
mapStyle = style.ensureStyleValidity(mapStyle);
|
mapStyle = ensureStyleValidity(mapStyle);
|
||||||
const key = styleKey(mapStyle.id);
|
const key = styleKey(mapStyle.id);
|
||||||
|
|
||||||
const saveFn = () => {
|
const saveFn = () => {
|
||||||
|
|||||||
@@ -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
@@ -45,15 +45,6 @@ function ensureStyleValidity(style: StyleSpecification): StyleSpecificationWithI
|
|||||||
return ensureHasNoInteractive(ensureHasNoRefs(ensureHasId(style)));
|
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}) {
|
function getAccessToken(sourceName: string, mapStyle: StyleSpecification, opts: {allowFallback?: boolean}) {
|
||||||
const metadata = mapStyle.metadata || {} as any;
|
const metadata = mapStyle.metadata || {} as any;
|
||||||
let accessToken = metadata[`maputnik:${sourceName}_access_token`];
|
let accessToken = metadata[`maputnik:${sourceName}_access_token`];
|
||||||
@@ -148,10 +139,9 @@ function stripAccessTokens(mapStyle: StyleSpecification) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export {
|
||||||
ensureStyleValidity,
|
ensureStyleValidity,
|
||||||
emptyStyle,
|
emptyStyle,
|
||||||
indexOfLayer,
|
|
||||||
generateId,
|
generateId,
|
||||||
getAccessToken,
|
getAccessToken,
|
||||||
replaceAccessTokens,
|
replaceAccessTokens,
|
||||||
|
|||||||
+3
-3
@@ -1,4 +1,4 @@
|
|||||||
import style from "./style";
|
import { emptyStyle, ensureStyleValidity } from "./style";
|
||||||
import { type StyleSpecificationWithId } from "./definitions";
|
import { type StyleSpecificationWithId } from "./definitions";
|
||||||
|
|
||||||
export function getStyleUrlFromAddressbarAndRemoveItIfNeeded(): string | null {
|
export function getStyleUrlFromAddressbarAndRemoveItIfNeeded(): string | null {
|
||||||
@@ -19,10 +19,10 @@ export async function loadStyleUrl(styleUrl: string): Promise<StyleSpecification
|
|||||||
credentials: "same-origin"
|
credentials: "same-origin"
|
||||||
});
|
});
|
||||||
const body = await response.json();
|
const body = await response.json();
|
||||||
return style.ensureStyleValidity(body);
|
return ensureStyleValidity(body);
|
||||||
} catch {
|
} catch {
|
||||||
console.warn("Could not fetch default style: " + styleUrl);
|
console.warn("Could not fetch default style: " + styleUrl);
|
||||||
return style.emptyStyle;
|
return emptyStyle;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user