mirror of
https://github.com/maputnik/editor.git
synced 2026-07-26 07:47:25 +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
|
||||
```
|
||||
|
||||
Run the unit and component tests with Vitest:
|
||||
Run the unit tests with Vitest:
|
||||
|
||||
```
|
||||
npm run test-unit
|
||||
@@ -45,3 +45,106 @@ npm run test-unit
|
||||
## Pull Requests
|
||||
|
||||
- Pull requests should update `CHANGELOG.md` with a short description of the change.
|
||||
|
||||
## Testing
|
||||
|
||||
### Prefer end-to-end tests
|
||||
|
||||
Most of this codebase is React components, and they are only reachable from an
|
||||
end-to-end test. E2E coverage is the primary signal.
|
||||
|
||||
Reach for a unit test only for pure logic that e2e cannot cheaply reach (parsers,
|
||||
sorting, watchers, stores). Before writing one, check whether e2e already covers
|
||||
the file — a unit test that duplicates existing e2e coverage adds test code and
|
||||
almost no coverage:
|
||||
|
||||
```
|
||||
npx nyc report --reporter=text --include="src/libs/style.ts"
|
||||
```
|
||||
|
||||
Do **not** merge the Vitest (v8) and e2e (istanbul) coverage reports locally. They
|
||||
produce conflicting statement maps for the same files and the combined percentage
|
||||
is meaningless. Codecov merges the two uploads server-side; that is the number to
|
||||
trust. Locally, read them separately:
|
||||
|
||||
- e2e: `npx playwright test` then `npx nyc report --reporter=text-summary` (reads `.nyc_output/`)
|
||||
- unit: `npx vitest run --coverage` (writes `coverage/`)
|
||||
|
||||
### E2E layering
|
||||
|
||||
Three layers, and the boundaries matter:
|
||||
|
||||
- `e2e/playwright-helper.ts` — generic, app-agnostic browser actions. **The only
|
||||
file allowed to import `@playwright/test`** (besides `e2e/utils/fixtures.ts`).
|
||||
- `e2e/maputnik-driver.ts` — domain actions (layers, filters, functions, the
|
||||
style). Knows nothing about `page` or Playwright.
|
||||
- `e2e/modal-driver.ts` — actions scoped to a modal, exposed as `when.modal.*`.
|
||||
|
||||
Specs get a driver at describe scope and assert fluently:
|
||||
|
||||
```ts
|
||||
describe("layer editor", () => {
|
||||
const { given, get, when, then } = new MaputnikDriver();
|
||||
...
|
||||
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ layers: [{ id, type: "fill" }] });
|
||||
});
|
||||
```
|
||||
|
||||
New UI interactions belong in a driver, not inline in a spec.
|
||||
|
||||
### Writing assertions
|
||||
|
||||
- `shouldDeepNestedInclude` is a recursive partial match (`toMatchObject`): nested
|
||||
objects are matched as subsets, arrays and primitives must match exactly
|
||||
(including array length).
|
||||
- Assert against the whole style, not an extracted slice. Avoid
|
||||
`get.styleFromLocalStorage().then(style => style.layers.find(...))` — it moves
|
||||
test logic into the test. Compare the real object instead.
|
||||
- `Query.then()` is lazy and returns a new `Query`, **not** a Promise. `await
|
||||
get.styleFromLocalStorage()` hangs forever. Use `.get()` to await it directly,
|
||||
or pass the Query to `then(...)`.
|
||||
|
||||
### One behaviour per test
|
||||
|
||||
If a test needs comments narrating "and now this…", it is several tests. Split it,
|
||||
and hoist the shared setup into a nested `describe` + `beforeEach`.
|
||||
|
||||
### Test ids
|
||||
|
||||
Test ids use the `data-wd-key` attribute and are read via `get.elementByTestId`.
|
||||
|
||||
The `Input*` components already accept `data-wd-key` and render it on the real
|
||||
`<input>`; the `Field*` wrappers forward it through their `{...props}` spread. So
|
||||
passing `data-wd-key` to a `Field*` component is usually enough. Do **not** also
|
||||
add it to `Block`/`Fieldset` — the id then matches two elements and locators fail
|
||||
in strict mode.
|
||||
|
||||
Note `InputNumber` renders `<key>-text` and `<key>-range` when `allowRange` is set,
|
||||
and `<key>` otherwise.
|
||||
|
||||
### Input commit semantics (common source of "the value didn't save")
|
||||
|
||||
- `InputString` only fires its `onChange` on **blur** or **Enter**. Typing alone
|
||||
fires `onInput`. A driver that calls `fill()` must then call `blur()`, or the
|
||||
value never reaches the style.
|
||||
- `InputNumber` commits on every change; no blur needed.
|
||||
- The autocomplete inputs (layer source, add-layer source) are controlled
|
||||
downshift comboboxes. Keystroke typing is dropped/reordered — `{selectall}` then
|
||||
typing `raster` yields `"exampleaster"`. Use `fill()`, which dispatches a single
|
||||
input event, then pick from the filtered menu.
|
||||
- CodeMirror auto-closes brackets and quotes, and types over its own closers, so
|
||||
inserting a well-formed JSON fragment stays well-formed. To break JSON on
|
||||
purpose, insert a bare word.
|
||||
|
||||
### Fixtures
|
||||
|
||||
Style fixtures live in `e2e/fixtures/`. A new one must be registered in two places
|
||||
in `maputnik-driver.ts`: the list in `given.setupMockBackedResponses` and the
|
||||
`styleFileByKey` map in `when.setStyle`.
|
||||
|
||||
### Verify a new test can fail
|
||||
|
||||
A test that passes for the wrong reason is worse than no test. After writing one,
|
||||
mutate the expected value and confirm it fails. This has caught real mistakes
|
||||
(e.g. a driver that never committed its input, so the assertion was matching a
|
||||
value written by the *previous* step).
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { test, expect, describe, beforeEach } from "./utils/fixtures";
|
||||
import { MaputnikDriver } from "./maputnik-driver";
|
||||
import tokens from "../src/config/tokens.json" with { type: "json" };
|
||||
|
||||
describe("access tokens", () => {
|
||||
const { given, when } = new MaputnikDriver();
|
||||
|
||||
const tileJson = {
|
||||
tilejson: "2.2.0",
|
||||
tiles: ["https://example.local/{z}/{x}/{y}.pbf"],
|
||||
minzoom: 0,
|
||||
maxzoom: 14,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await given.setupMockBackedResponses();
|
||||
});
|
||||
|
||||
test("uses the thunderforest token for a thunderforest source", async () => {
|
||||
await given.interceptAndMockResponse({
|
||||
method: "GET",
|
||||
url: /tile\.thunderforest\.com\/.*/,
|
||||
response: tileJson,
|
||||
alias: "thunderforest",
|
||||
});
|
||||
|
||||
await when.setStyle("access_tokens");
|
||||
|
||||
const request = await when.waitForResponse("thunderforest");
|
||||
expect(request.url()).toContain(`apikey=${tokens.thunderforest}`);
|
||||
});
|
||||
|
||||
test("uses the locationiq token for a locationiq source", async () => {
|
||||
await given.interceptAndMockResponse({
|
||||
method: "GET",
|
||||
url: /tiles\.locationiq\.com\/.*/,
|
||||
response: tileJson,
|
||||
alias: "locationiq",
|
||||
});
|
||||
|
||||
await when.setStyle("access_tokens");
|
||||
|
||||
const request = await when.waitForResponse("locationiq");
|
||||
expect(request.url()).toContain(`key=${tokens.locationiq}`);
|
||||
});
|
||||
|
||||
test("appends the stadia token as a query parameter", async () => {
|
||||
await given.interceptAndMockResponse({
|
||||
method: "GET",
|
||||
url: /tiles\.stadiamaps\.com\/.*/,
|
||||
response: tileJson,
|
||||
alias: "stadia",
|
||||
});
|
||||
|
||||
await when.setStyle("access_tokens");
|
||||
|
||||
const request = await when.waitForResponse("stadia");
|
||||
expect(request.url()).toContain("?api_key=stadia-test-token");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"version": 8,
|
||||
"name": "Access token style",
|
||||
"metadata": {
|
||||
"maputnik:stadia_access_token": "stadia-test-token"
|
||||
},
|
||||
"sources": {
|
||||
"thunderforest_transport": {
|
||||
"type": "vector",
|
||||
"url": "https://tile.thunderforest.com/thunderforest.transport-v2.json?apikey={key}"
|
||||
},
|
||||
"stadia_outdoors": {
|
||||
"type": "vector",
|
||||
"url": "https://tiles.stadiamaps.com/data/openmaptiles.json"
|
||||
},
|
||||
"locationiq": {
|
||||
"type": "vector",
|
||||
"url": "https://tiles.locationiq.com/v3/pbf/tiles.json?key={key}"
|
||||
}
|
||||
},
|
||||
"layers": []
|
||||
}
|
||||
+319
-74
@@ -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
@@ -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
@@ -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"),
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -145,6 +145,7 @@ async function typeSequence(page: Page, text: string): Promise<void> {
|
||||
del: "Delete",
|
||||
tab: "Tab",
|
||||
home: "Home",
|
||||
end: "End",
|
||||
rightarrow: "ArrowRight",
|
||||
};
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ import ModalDebug from "./modals/ModalDebug";
|
||||
import ModalGlobalState from "./modals/ModalGlobalState";
|
||||
|
||||
import {downloadGlyphsMetadata, downloadSpriteMetadata} from "../libs/metadata";
|
||||
import style from "../libs/style";
|
||||
import { emptyStyle, getAccessToken, replaceAccessTokens } from "../libs/style";
|
||||
import { undoMessages, redoMessages } from "../libs/diffmessage";
|
||||
import { createStyleStore, type IStyleStore } from "../libs/store/style-store-factory";
|
||||
import { RevisionStore } from "../libs/revisions";
|
||||
@@ -48,19 +48,19 @@ function setFetchAccessToken(url: string, mapStyle: StyleSpecification) {
|
||||
const matchesThunderforest = url.match(/\.thunderforest\.com/);
|
||||
const matchesLocationIQ = url.match(/\.locationiq\.com/);
|
||||
if (matchesTilehosting || matchesMaptiler) {
|
||||
const accessToken = style.getAccessToken("openmaptiles", mapStyle, {allowFallback: true});
|
||||
const accessToken = getAccessToken("openmaptiles", mapStyle, {allowFallback: true});
|
||||
if (accessToken) {
|
||||
return url.replace("{key}", accessToken);
|
||||
}
|
||||
}
|
||||
else if (matchesThunderforest) {
|
||||
const accessToken = style.getAccessToken("thunderforest", mapStyle, {allowFallback: true});
|
||||
const accessToken = getAccessToken("thunderforest", mapStyle, {allowFallback: true});
|
||||
if (accessToken) {
|
||||
return url.replace("{key}", accessToken);
|
||||
}
|
||||
}
|
||||
else if (matchesLocationIQ) {
|
||||
const accessToken = style.getAccessToken("locationiq", mapStyle, {allowFallback: true});
|
||||
const accessToken = getAccessToken("locationiq", mapStyle, {allowFallback: true});
|
||||
if (accessToken) {
|
||||
return url.replace("{key}", accessToken);
|
||||
}
|
||||
@@ -137,7 +137,7 @@ export default class App extends React.Component<any, AppState> {
|
||||
this.state = {
|
||||
errors: [],
|
||||
infos: [],
|
||||
mapStyle: style.emptyStyle,
|
||||
mapStyle: emptyStyle,
|
||||
selectedLayerIndex: 0,
|
||||
sources: {},
|
||||
vectorLayers: {},
|
||||
@@ -698,7 +698,7 @@ export default class App extends React.Component<any, AppState> {
|
||||
mapStyle: (dirtyMapStyle || mapStyle),
|
||||
mapView: this.state.mapView,
|
||||
replaceAccessTokens: (mapStyle: StyleSpecification) => {
|
||||
return style.replaceAccessTokens(mapStyle, {
|
||||
return replaceAccessTokens(mapStyle, {
|
||||
allowFallback: true
|
||||
});
|
||||
},
|
||||
|
||||
@@ -215,6 +215,7 @@ class FilterEditorInternal extends React.Component<FilterEditorInternalProps, Fi
|
||||
onClick={this.makeExpression}
|
||||
title={t("Convert to expression")}
|
||||
className="maputnik-make-zoom-function"
|
||||
data-wd-key="filter-convert-to-expression"
|
||||
>
|
||||
<TbMathFunction />
|
||||
</InputButton>
|
||||
@@ -248,6 +249,7 @@ class FilterEditorInternal extends React.Component<FilterEditorInternalProps, Fi
|
||||
fieldSpec={fieldSpec}
|
||||
label={t("Filter")}
|
||||
action={actions}
|
||||
data-wd-key="filter-combining-operator"
|
||||
>
|
||||
<InputSelect
|
||||
value={combiningOp}
|
||||
|
||||
@@ -230,6 +230,7 @@ class LayerEditorInternal extends React.Component<LayerEditorInternalProps, Laye
|
||||
)}
|
||||
/>
|
||||
{this.props.layer.type !== "background" && <FieldSource
|
||||
wdKey="layer-editor.layer-source"
|
||||
error={errorData.source}
|
||||
sourceIds={Object.keys(this.props.sources!)}
|
||||
value={this.props.layer.source}
|
||||
|
||||
@@ -289,6 +289,7 @@ class DataPropertyInternal extends React.Component<DataPropertyInternalProps, Da
|
||||
<Block
|
||||
label={t("Function")}
|
||||
key="function"
|
||||
data-wd-key="function-type"
|
||||
>
|
||||
<div className="maputnik-data-spec-property-input">
|
||||
<InputSelect
|
||||
@@ -303,6 +304,7 @@ class DataPropertyInternal extends React.Component<DataPropertyInternalProps, Da
|
||||
<Block
|
||||
label={t("Base")}
|
||||
key="base"
|
||||
data-wd-key="function-base"
|
||||
>
|
||||
<div className="maputnik-data-spec-property-input">
|
||||
<InputSpec
|
||||
@@ -317,6 +319,7 @@ class DataPropertyInternal extends React.Component<DataPropertyInternalProps, Da
|
||||
<Block
|
||||
label={"Property"}
|
||||
key="property"
|
||||
data-wd-key="function-property"
|
||||
>
|
||||
<div className="maputnik-data-spec-property-input">
|
||||
<InputString
|
||||
@@ -330,6 +333,7 @@ class DataPropertyInternal extends React.Component<DataPropertyInternalProps, Da
|
||||
<Block
|
||||
label={t("Default")}
|
||||
key="default"
|
||||
data-wd-key="function-default"
|
||||
>
|
||||
<InputSpec
|
||||
fieldName={this.props.fieldName}
|
||||
@@ -368,6 +372,7 @@ class DataPropertyInternal extends React.Component<DataPropertyInternalProps, Da
|
||||
}
|
||||
<InputButton
|
||||
className="maputnik-add-stop"
|
||||
data-wd-key="convert-to-expression"
|
||||
onClick={this.props.onExpressionClick?.bind(this)}
|
||||
>
|
||||
<TbMathFunction style={{ verticalAlign: "text-bottom" }} />
|
||||
|
||||
@@ -50,6 +50,7 @@ class ExpressionPropertyInternal extends React.Component<ExpressionPropertyInter
|
||||
onClick={this.props.onUndo}
|
||||
disabled={undoDisabled}
|
||||
className="maputnik-delete-stop"
|
||||
data-wd-key="undo-expression"
|
||||
title={t("Revert from expression")}
|
||||
>
|
||||
<MdUndo />
|
||||
@@ -59,6 +60,7 @@ class ExpressionPropertyInternal extends React.Component<ExpressionPropertyInter
|
||||
key="delete_action"
|
||||
onClick={this.props.onDelete}
|
||||
className="maputnik-delete-stop"
|
||||
data-wd-key="delete-expression"
|
||||
title={t("Delete expression")}
|
||||
>
|
||||
<MdDelete />
|
||||
|
||||
@@ -194,6 +194,7 @@ class ZoomPropertyInternal extends React.Component<ZoomPropertyInternalProps, Zo
|
||||
<div className="maputnik-data-fieldset-inner">
|
||||
<Block
|
||||
label={t("Function")}
|
||||
data-wd-key="function-type"
|
||||
>
|
||||
<div className="maputnik-data-spec-property-input">
|
||||
<InputSelect
|
||||
@@ -206,6 +207,7 @@ class ZoomPropertyInternal extends React.Component<ZoomPropertyInternalProps, Zo
|
||||
</Block>
|
||||
<Block
|
||||
label={t("Base")}
|
||||
data-wd-key="function-base"
|
||||
>
|
||||
<div className="maputnik-data-spec-property-input">
|
||||
<InputSpec
|
||||
@@ -240,6 +242,7 @@ class ZoomPropertyInternal extends React.Component<ZoomPropertyInternalProps, Zo
|
||||
</InputButton>
|
||||
<InputButton
|
||||
className="maputnik-add-stop"
|
||||
data-wd-key="convert-to-expression"
|
||||
onClick={this.props.onExpressionClick?.bind(this)}
|
||||
>
|
||||
<TbMathFunction style={{ verticalAlign: "text-bottom" }} />
|
||||
|
||||
@@ -9,7 +9,7 @@ import {type WithTranslation, withTranslation} from "react-i18next";
|
||||
import FieldString from "../FieldString";
|
||||
import InputButton from "../InputButton";
|
||||
import Modal from "./Modal";
|
||||
import style from "../../libs/style";
|
||||
import {replaceAccessTokens, stripAccessTokens} from "../../libs/style";
|
||||
import fieldSpecAdditional from "../../libs/field-spec-additional";
|
||||
import type {OnStyleChangedCallback, StyleSpecificationWithId} from "../../libs/definitions";
|
||||
|
||||
@@ -32,8 +32,8 @@ class ModalExportInternal extends React.Component<ModalExportInternalProps> {
|
||||
|
||||
tokenizedStyle() {
|
||||
return format(
|
||||
style.stripAccessTokens(
|
||||
style.replaceAccessTokens(this.props.mapStyle)
|
||||
stripAccessTokens(
|
||||
replaceAccessTokens(this.props.mapStyle)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import Modal from "./Modal";
|
||||
import InputButton from "../InputButton";
|
||||
import InputUrl from "../InputUrl";
|
||||
|
||||
import style from "../../libs/style";
|
||||
import { ensureStyleValidity } from "../../libs/style";
|
||||
import publicStyles from "../../config/styles.json";
|
||||
|
||||
type PublicStyleProps = {
|
||||
@@ -109,7 +109,7 @@ class ModalOpenInternal extends React.Component<ModalOpenInternalProps, ModalOpe
|
||||
activeRequestUrl: null
|
||||
});
|
||||
|
||||
const mapStyle = style.ensureStyleValidity(body);
|
||||
const mapStyle = ensureStyleValidity(body);
|
||||
console.log("Loaded style ", mapStyle.id);
|
||||
this.props.onStyleOpen(mapStyle);
|
||||
this.onOpenToggle();
|
||||
@@ -165,7 +165,7 @@ class ModalOpenInternal extends React.Component<ModalOpenInternalProps, ModalOpe
|
||||
});
|
||||
return;
|
||||
}
|
||||
mapStyle = style.ensureStyleValidity(mapStyle);
|
||||
mapStyle = ensureStyleValidity(mapStyle);
|
||||
|
||||
this.props.onStyleOpen(mapStyle, fileHandle);
|
||||
this.onOpenToggle();
|
||||
@@ -193,7 +193,7 @@ class ModalOpenInternal extends React.Component<ModalOpenInternalProps, ModalOpe
|
||||
});
|
||||
return;
|
||||
}
|
||||
mapStyle = style.ensureStyleValidity(mapStyle);
|
||||
mapStyle = ensureStyleValidity(mapStyle);
|
||||
this.props.onStyleOpen(mapStyle);
|
||||
this.onOpenToggle();
|
||||
};
|
||||
|
||||
@@ -206,6 +206,7 @@ class ModalSettingsInternal extends React.Component<ModalSettingsInternalProps>
|
||||
|
||||
<FieldNumber
|
||||
label={t("Zoom")}
|
||||
data-wd-key="modal:settings.zoom"
|
||||
fieldSpec={latest.$root.zoom}
|
||||
value={mapStyle.zoom}
|
||||
default={0}
|
||||
@@ -214,6 +215,7 @@ class ModalSettingsInternal extends React.Component<ModalSettingsInternalProps>
|
||||
|
||||
<FieldNumber
|
||||
label={t("Bearing")}
|
||||
data-wd-key="modal:settings.bearing"
|
||||
fieldSpec={latest.$root.bearing}
|
||||
value={mapStyle.bearing}
|
||||
default={latest.$root.bearing.default}
|
||||
@@ -222,6 +224,7 @@ class ModalSettingsInternal extends React.Component<ModalSettingsInternalProps>
|
||||
|
||||
<FieldNumber
|
||||
label={t("Pitch")}
|
||||
data-wd-key="modal:settings.pitch"
|
||||
fieldSpec={latest.$root.pitch}
|
||||
value={mapStyle.pitch}
|
||||
default={latest.$root.pitch.default}
|
||||
@@ -248,6 +251,7 @@ class ModalSettingsInternal extends React.Component<ModalSettingsInternalProps>
|
||||
|
||||
<FieldNumber
|
||||
label={t("Light intensity")}
|
||||
data-wd-key="modal:settings.light-intensity"
|
||||
fieldSpec={latest.light.intensity}
|
||||
value={light.intensity as number}
|
||||
default={latest.light.intensity.default}
|
||||
@@ -274,6 +278,7 @@ class ModalSettingsInternal extends React.Component<ModalSettingsInternalProps>
|
||||
|
||||
<FieldNumber
|
||||
label={t("Terrain exaggeration")}
|
||||
data-wd-key="modal:settings.terrain-exaggeration"
|
||||
fieldSpec={latest.terrain.exaggeration}
|
||||
value={terrain.exaggeration}
|
||||
default={latest.terrain.exaggeration.default}
|
||||
@@ -282,6 +287,7 @@ class ModalSettingsInternal extends React.Component<ModalSettingsInternalProps>
|
||||
|
||||
<FieldNumber
|
||||
label={t("Transition delay")}
|
||||
data-wd-key="modal:settings.transition-delay"
|
||||
fieldSpec={latest.transition.delay}
|
||||
value={transition.delay}
|
||||
default={latest.transition.delay.default}
|
||||
@@ -290,6 +296,7 @@ class ModalSettingsInternal extends React.Component<ModalSettingsInternalProps>
|
||||
|
||||
<FieldNumber
|
||||
label={t("Transition duration")}
|
||||
data-wd-key="modal:settings.transition-duration"
|
||||
fieldSpec={latest.transition.duration}
|
||||
value={transition.duration}
|
||||
default={latest.transition.duration.default}
|
||||
|
||||
@@ -10,7 +10,7 @@ import FieldString from "../FieldString";
|
||||
import FieldSelect from "../FieldSelect";
|
||||
import ModalSourcesTypeEditor, { type EditorMode } from "./ModalSourcesTypeEditor";
|
||||
|
||||
import style from "../../libs/style";
|
||||
import { generateId } from "../../libs/style";
|
||||
import { deleteSource, addSource, changeSource } from "../../libs/source";
|
||||
import publicSources from "../../config/tilesets.json";
|
||||
import { type OnStyleChangedCallback, type StyleSpecificationWithId } from "../../libs/definitions";
|
||||
@@ -121,7 +121,7 @@ class AddSource extends React.Component<AddSourceProps, AddSourceState> {
|
||||
super(props);
|
||||
this.state = {
|
||||
mode: "tilejson_vector",
|
||||
sourceId: style.generateId(),
|
||||
sourceId: generateId(),
|
||||
source: this.defaultSource("tilejson_vector"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 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)
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import style from "../style";
|
||||
import {ensureStyleValidity} from "../style";
|
||||
import {loadStyleUrl} from "../urlopen";
|
||||
import publicSources from "../../config/styles.json";
|
||||
import type {IStyleStore, StyleSpecificationWithId} from "../definitions";
|
||||
@@ -89,7 +89,7 @@ export class StyleStore implements IStyleStore {
|
||||
|
||||
// Save current style replacing previous version
|
||||
save(mapStyle: StyleSpecificationWithId) {
|
||||
mapStyle = style.ensureStyleValidity(mapStyle);
|
||||
mapStyle = ensureStyleValidity(mapStyle);
|
||||
const key = styleKey(mapStyle.id);
|
||||
|
||||
const saveFn = () => {
|
||||
|
||||
@@ -1,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)));
|
||||
}
|
||||
|
||||
function indexOfLayer(layers: LayerSpecification[], layerId: string) {
|
||||
for (let i = 0; i < layers.length; i++) {
|
||||
if(layers[i].id === layerId) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getAccessToken(sourceName: string, mapStyle: StyleSpecification, opts: {allowFallback?: boolean}) {
|
||||
const metadata = mapStyle.metadata || {} as any;
|
||||
let accessToken = metadata[`maputnik:${sourceName}_access_token`];
|
||||
@@ -148,10 +139,9 @@ function stripAccessTokens(mapStyle: StyleSpecification) {
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
export {
|
||||
ensureStyleValidity,
|
||||
emptyStyle,
|
||||
indexOfLayer,
|
||||
generateId,
|
||||
getAccessToken,
|
||||
replaceAccessTokens,
|
||||
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
import style from "./style";
|
||||
import { emptyStyle, ensureStyleValidity } from "./style";
|
||||
import { type StyleSpecificationWithId } from "./definitions";
|
||||
|
||||
export function getStyleUrlFromAddressbarAndRemoveItIfNeeded(): string | null {
|
||||
@@ -19,10 +19,10 @@ export async function loadStyleUrl(styleUrl: string): Promise<StyleSpecification
|
||||
credentials: "same-origin"
|
||||
});
|
||||
const body = await response.json();
|
||||
return style.ensureStyleValidity(body);
|
||||
return ensureStyleValidity(body);
|
||||
} catch {
|
||||
console.warn("Could not fetch default style: " + styleUrl);
|
||||
return style.emptyStyle;
|
||||
return emptyStyle;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user