mirror of
https://github.com/maputnik/editor.git
synced 2026-07-14 09:57:29 +00:00
Increase coverage (#1997)
## Launch Checklist This PR increases coverage by adding unit tests to lib folde, replace the skipped end to end placeholder with actual tests and adds more end to end tests. This was mostly done by AI (Claude opus 4.8) and I reviewed it and requested changes where needed. - [x] Briefly describe the changes in this PR. - [x] Link to related issues. - [x] Include before/after visuals or gifs if this PR includes visual changes. - [x] Write tests for all new functionality. - [x] Add an entry to `CHANGELOG.md` under the `## main` section. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
@@ -96,7 +96,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version-file: '.nvmrc'
|
node-version-file: '.nvmrc'
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npx playwright install --with-deps chromium
|
|
||||||
- run: npm run test-unit-ci
|
- run: npm run test-unit-ci
|
||||||
- name: Upload coverage reports to Codecov
|
- name: Upload coverage reports to Codecov
|
||||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ Then run the end-to-end tests (Playwright starts the dev server automatically):
|
|||||||
npm run test
|
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": []
|
||||||
|
}
|
||||||
+367
-9
@@ -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();
|
||||||
@@ -76,6 +87,14 @@ describe("layer editor", () => {
|
|||||||
layers: [{ id: "background:" + bgId, type: "background", minzoom: 1 }],
|
layers: [{ id: "background:" + bgId, type: "background", minzoom: 1 }],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("the range slider adjusts min-zoom", async () => {
|
||||||
|
await when.focus("min-zoom.input-range");
|
||||||
|
await when.typeKeys("{rightarrow}");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id: "background:" + bgId, type: "background", minzoom: 2 }],
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("max-zoom", () => {
|
describe("max-zoom", () => {
|
||||||
@@ -145,6 +164,13 @@ describe("layer editor", () => {
|
|||||||
layers: [{ id: "background:" + bgId, type: "background" }],
|
layers: [{ id: "background:" + bgId, type: "background" }],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("typing a hex value updates the paint color", async () => {
|
||||||
|
await when.setColorValue("background-color", "#ff0000");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id: "background:" + bgId, type: "background", paint: { "background-color": "#ff0000" } }],
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("opacity", () => {
|
describe("opacity", () => {
|
||||||
@@ -166,8 +192,287 @@ describe("layer editor", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("filter", () => {
|
describe("filter", () => {
|
||||||
test.skip("expand/collapse", () => {});
|
let id: string;
|
||||||
test.skip("compound filter", () => {});
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
id = await when.modal.fillLayers({ type: "fill", layer: "example" });
|
||||||
|
await when.addFilter();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should add a filter item", async () => {
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, type: "fill", source: "example", filter: ["all", ["==", "name", ""]] }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should change the filter operator", async () => {
|
||||||
|
await when.selectFilterOperator("!=");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, filter: ["all", ["!=", "name", ""]] }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should extend the compound filter with a second item", async () => {
|
||||||
|
await when.addFilter();
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, filter: ["all", ["==", "name", ""], ["==", "name", ""]] }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should change the combining operator", async () => {
|
||||||
|
await when.selectFilterCombiningOperator("any");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, filter: ["any", ["==", "name", ""]] }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should delete a filter item", async () => {
|
||||||
|
await when.addFilter();
|
||||||
|
await when.deleteFilterItem();
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, filter: ["all", ["==", "name", ""]] }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when converted to an expression", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await when.convertFilterToExpression();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should migrate the filter to an expression", async () => {
|
||||||
|
// A single-item "all" collapses to the bare comparison when migrated.
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, filter: ["==", ["get", "name"], ""] }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should restore the default filter when the expression is deleted", async () => {
|
||||||
|
await when.deleteFilterExpression();
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, filter: ["all"] }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("functions", () => {
|
||||||
|
let id: string;
|
||||||
|
|
||||||
|
describe("zoom function", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
id = await when.modal.fillLayers({ type: "circle", layer: "example" });
|
||||||
|
await when.makeZoomFunction("circle-radius");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should convert the property to a zoom function", async () => {
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [
|
||||||
|
{ id, type: "circle", source: "example", paint: { "circle-radius": { stops: [[6, 5], [10, 5]] } } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should add a stop", async () => {
|
||||||
|
await when.addFunctionStop("circle-radius");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, paint: { "circle-radius": { stops: [[6, 5], [10, 5], [11, 5]] } } }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should delete the first stop", async () => {
|
||||||
|
// A function needs more than two stops, otherwise deleting one collapses
|
||||||
|
// it back into a plain value.
|
||||||
|
await when.addFunctionStop("circle-radius");
|
||||||
|
await when.deleteFunctionStop("circle-radius");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, paint: { "circle-radius": { stops: [[10, 5], [11, 5]] } } }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should set the base", async () => {
|
||||||
|
await when.setFunctionBase("circle-radius", "2");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, paint: { "circle-radius": { base: 2, stops: [[6, 5], [10, 5]] } } }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should edit the zoom of a stop", async () => {
|
||||||
|
await when.setFunctionStopValue("circle-radius", "Zoom", 0, "3");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, paint: { "circle-radius": { stops: [[3, 5], [10, 5]] } } }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should edit the output value of a stop", async () => {
|
||||||
|
await when.setFunctionStopValue("circle-radius", "Output value", 0, "9");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, paint: { "circle-radius": { stops: [[6, 9], [10, 5]] } } }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should convert to an expression", async () => {
|
||||||
|
await when.makeExpression("circle-radius");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, paint: { "circle-radius": ["interpolate", ["linear"], ["zoom"], 6, 5, 10, 5] } }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when converted to a data function", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Any non-interpolate scale turns the zoom function into a data one.
|
||||||
|
await when.selectFunctionType("circle-radius", "categorical");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should carry the stops over as zoom/value pairs", async () => {
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
paint: {
|
||||||
|
"circle-radius": {
|
||||||
|
property: "",
|
||||||
|
type: "exponential",
|
||||||
|
stops: [[{ zoom: 6, value: 0 }, 5], [{ zoom: 10, value: 0 }, 5]],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should convert back to a zoom function", async () => {
|
||||||
|
await when.selectFunctionType("circle-radius", "interpolate");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, paint: { "circle-radius": { stops: [[6, 5], [10, 5]] } } }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("data function", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
id = await when.modal.fillLayers({ type: "circle", layer: "example" });
|
||||||
|
await when.setValue("spec-field-input:circle-blur", "1");
|
||||||
|
await when.makeDataFunction("circle-blur");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should convert the property to a data function", async () => {
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
type: "circle",
|
||||||
|
source: "example",
|
||||||
|
paint: {
|
||||||
|
"circle-blur": {
|
||||||
|
property: "",
|
||||||
|
type: "exponential",
|
||||||
|
stops: [[{ zoom: 6, value: 0 }, 1], [{ zoom: 10, value: 0 }, 1]],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should add a stop", async () => {
|
||||||
|
await when.addFunctionStop("circle-blur");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
paint: {
|
||||||
|
"circle-blur": {
|
||||||
|
stops: [[{ zoom: 6, value: 0 }, 1], [{ zoom: 10, value: 0 }, 1], [{ zoom: 11, value: 0 }, 1]],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should delete the first stop", async () => {
|
||||||
|
await when.addFunctionStop("circle-blur");
|
||||||
|
await when.deleteFunctionStop("circle-blur");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
paint: {
|
||||||
|
"circle-blur": {
|
||||||
|
stops: [[{ zoom: 10, value: 0 }, 1], [{ zoom: 11, value: 0 }, 1]],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should set the property", async () => {
|
||||||
|
await when.setFunctionProperty("circle-blur", "myprop");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, paint: { "circle-blur": { property: "myprop" } } }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should set the default", async () => {
|
||||||
|
await when.setFunctionDefault("circle-blur", "0.5");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, paint: { "circle-blur": { default: 0.5 } } }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should edit the input value of a stop", async () => {
|
||||||
|
await when.setFunctionStopValue("circle-blur", "Input value", 0, "7");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
paint: {
|
||||||
|
"circle-blur": {
|
||||||
|
stops: [[{ zoom: 6, value: 7 }, 1], [{ zoom: 10, value: 0 }, 1]],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should change the function type", async () => {
|
||||||
|
await when.selectFunctionType("circle-blur", "categorical");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, paint: { "circle-blur": { type: "categorical" } } }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("expression", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
id = await when.modal.fillLayers({ type: "circle", layer: "example" });
|
||||||
|
await when.setValue("spec-field-input:circle-blur", "1");
|
||||||
|
await when.makeExpression("circle-blur");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should wrap the property value in a literal expression", async () => {
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, paint: { "circle-blur": ["literal", 1] } }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should restore the plain value when reverted", async () => {
|
||||||
|
await when.undoExpression("circle-blur");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, paint: { "circle-blur": 1 } }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should fall back to the spec default when deleted", async () => {
|
||||||
|
await when.deleteExpression("circle-blur");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, paint: { "circle-blur": 0 } }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("layout", () => {
|
describe("layout", () => {
|
||||||
@@ -183,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", () => {
|
||||||
@@ -206,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", () => {
|
||||||
|
|||||||
+167
-1
@@ -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);
|
||||||
@@ -107,7 +110,13 @@ export class MaputnikDriver {
|
|||||||
},
|
},
|
||||||
|
|
||||||
chooseExampleFile: async () => {
|
chooseExampleFile: async () => {
|
||||||
await this.helper.when.openFileByFixture("example-style.json", "modal:open.dropzone", "modal:open.file.input");
|
await this.helper.when.openFileByFixture("example-style.json", "modal:open.dropzone");
|
||||||
|
await this.helper.when.wait(200);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Picks the example style through the browser's native file chooser. */
|
||||||
|
chooseExampleFileFromPicker: async () => {
|
||||||
|
await this.helper.when.chooseFileFromPicker("example-style.json", "modal:open.dropzone");
|
||||||
await this.helper.when.wait(200);
|
await this.helper.when.wait(200);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -128,6 +137,22 @@ export class MaputnikDriver {
|
|||||||
await this.helper.get.element(".maputnik-layer-editor-group__button").nth(index).click();
|
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
|
||||||
@@ -158,6 +183,147 @@ export class MaputnikDriver {
|
|||||||
await this.helper.when.typeText(value);
|
await this.helper.when.typeText(value);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
makeZoomFunction: async (fieldName: string) => {
|
||||||
|
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
|
||||||
|
await container.scrollIntoViewIfNeeded();
|
||||||
|
await container.locator(".maputnik-make-zoom-function").last().click({ force: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
makeDataFunction: async (fieldName: string) => {
|
||||||
|
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
|
||||||
|
await container.scrollIntoViewIfNeeded();
|
||||||
|
await container.locator(".maputnik-make-data-function").click({ force: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
addFunctionStop: async (fieldName: string) => {
|
||||||
|
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
|
||||||
|
await container.locator(".maputnik-add-stop").first().click({ force: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteFunctionStop: async (fieldName: string) => {
|
||||||
|
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
|
||||||
|
await container.locator(".maputnik-delete-stop").first().click({ force: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Turns the property into a raw style expression. */
|
||||||
|
makeExpression: async (fieldName: string) => {
|
||||||
|
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
|
||||||
|
await container.scrollIntoViewIfNeeded();
|
||||||
|
// In the plain spec field the expression button shares the zoom-function
|
||||||
|
// class and comes first; inside a function editor it has its own test id.
|
||||||
|
const inFunctionEditor = container.locator("[data-wd-key='convert-to-expression']");
|
||||||
|
const button =
|
||||||
|
(await inFunctionEditor.count()) > 0
|
||||||
|
? inFunctionEditor
|
||||||
|
: container.locator(".maputnik-make-zoom-function").first();
|
||||||
|
await button.click({ force: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Reverts an expression back to a plain value. */
|
||||||
|
undoExpression: async (fieldName: string) => {
|
||||||
|
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
|
||||||
|
await container.locator("[data-wd-key='undo-expression']").click({ force: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Removes an expression, restoring the property's spec default. */
|
||||||
|
deleteExpression: async (fieldName: string) => {
|
||||||
|
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
|
||||||
|
await container.locator("[data-wd-key='delete-expression']").click({ force: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Picks the function scale (categorical/interval/exponential/identity/interpolate). */
|
||||||
|
selectFunctionType: async (fieldName: string, type: string) => {
|
||||||
|
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
|
||||||
|
await container.locator("[data-wd-key='function-type'] select").selectOption(type);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Sets the "Base" input of a zoom/data function. */
|
||||||
|
setFunctionBase: async (fieldName: string, value: string) => {
|
||||||
|
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
|
||||||
|
await container.locator("[data-wd-key='function-base'] input").fill(value);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the data property a data function keys off of. This is an InputString,
|
||||||
|
* which only commits its value on blur, so typing alone is not enough.
|
||||||
|
*/
|
||||||
|
setFunctionProperty: async (fieldName: string, value: string) => {
|
||||||
|
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
|
||||||
|
const input = container.locator("[data-wd-key='function-property'] input");
|
||||||
|
await input.fill(value);
|
||||||
|
await input.blur();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Sets the fallback value used when a feature has no matching stop. */
|
||||||
|
setFunctionDefault: async (fieldName: string, value: string) => {
|
||||||
|
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
|
||||||
|
await container.locator("[data-wd-key='function-default'] input").fill(value);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Edits one cell of a function's stop table ("Zoom", "Input value" or "Output value"). */
|
||||||
|
setFunctionStopValue: async (fieldName: string, column: string, index: number, value: string) => {
|
||||||
|
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
|
||||||
|
await container.locator(`[aria-label="${column}"]`).nth(index).fill(value);
|
||||||
|
},
|
||||||
|
|
||||||
|
addFilter: async () => {
|
||||||
|
const button = this.helper.get.elementByTestId("layer-filter-button");
|
||||||
|
await button.scrollIntoViewIfNeeded();
|
||||||
|
await button.click({ force: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
selectFilterOperator: async (value: string) => {
|
||||||
|
await this.helper.get.element(".maputnik-filter-editor-operator select").first().selectOption(value);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Chooses how the filter items combine: all / none / any. */
|
||||||
|
selectFilterCombiningOperator: async (value: string) => {
|
||||||
|
await this.helper.when.selectWithin("filter-combining-operator", value);
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteFilterItem: async (index = 0) => {
|
||||||
|
await this.helper.get
|
||||||
|
.element(".maputnik-filter-editor-block-action .maputnik-icon-button")
|
||||||
|
.nth(index)
|
||||||
|
.click();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Converts the simple filter editor into a raw expression editor. */
|
||||||
|
convertFilterToExpression: async () => {
|
||||||
|
await this.helper.when.click("filter-convert-to-expression");
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the filter expression, restoring the simple filter editor.
|
||||||
|
* The filter group precedes the paint group, so its button comes first.
|
||||||
|
*/
|
||||||
|
deleteFilterExpression: async () => {
|
||||||
|
await this.helper.get.element("[data-wd-key='delete-expression']").first().click();
|
||||||
|
},
|
||||||
|
|
||||||
|
setColorValue: async (fieldName: string, value: string) => {
|
||||||
|
const input = this.helper.get.elementByTestId("spec-field:" + fieldName).locator(".maputnik-color");
|
||||||
|
await input.fill(value);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Sets a plain string spec field (e.g. a pattern), which has no dedicated input test id. */
|
||||||
|
setStringValue: async (fieldName: string, value: string) => {
|
||||||
|
const input = this.helper.get.elementByTestId("spec-field:" + fieldName).locator("input.maputnik-string");
|
||||||
|
await input.fill(value);
|
||||||
|
await input.blur();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends text to the end of the JSON editor line holding `lineText`.
|
||||||
|
* CodeMirror types over its own auto-inserted closing quotes/brackets, so a
|
||||||
|
* well-formed fragment stays well-formed.
|
||||||
|
*/
|
||||||
|
appendToJsonEditorLine: async (lineText: string, text: string) => {
|
||||||
|
await this.helper.when.clickByText(lineText);
|
||||||
|
await this.helper.when.typeKeys("{end}");
|
||||||
|
await this.helper.when.typeText(text);
|
||||||
|
},
|
||||||
|
|
||||||
waitForExampleFileResponse: () => this.helper.when.waitForResponse("example-style.json"),
|
waitForExampleFileResponse: () => this.helper.when.waitForResponse("example-style.json"),
|
||||||
|
|
||||||
/** Fill localStorage until we get a QuotaExceededError. */
|
/** Fill localStorage until we get a QuotaExceededError. */
|
||||||
|
|||||||
@@ -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();
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+198
-26
@@ -43,6 +43,16 @@ describe("modals", () => {
|
|||||||
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude(get.responseBody("example-style.json"));
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude(get.responseBody("example-style.json"));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("without the File System Access API", () => {
|
||||||
|
test("upload via the file chooser", async () => {
|
||||||
|
await given.noFileSystemAccessApi();
|
||||||
|
await when.setStyle("");
|
||||||
|
await when.click("nav:open");
|
||||||
|
await when.chooseExampleFileFromPicker();
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude(get.fixture("example-style.json"));
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("shortcuts", () => {
|
describe("shortcuts", () => {
|
||||||
@@ -64,8 +74,15 @@ describe("modals", () => {
|
|||||||
await then(get.elementByTestId("modal:export")).shouldNotExist();
|
await then(get.elementByTestId("modal:export")).shouldNotExist();
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO: Work out how to download a file and check the contents
|
test("download HTML and save the style", async () => {
|
||||||
test.skip("download", () => {});
|
// Generate the standalone HTML export (triggers a file download).
|
||||||
|
await when.modal.exportCreateHtml();
|
||||||
|
await then(get.elementByTestId("modal:export")).shouldExist();
|
||||||
|
|
||||||
|
// Saving the style closes the export modal.
|
||||||
|
await when.modal.exportSaveStyle();
|
||||||
|
await then(get.elementByTestId("modal:export")).shouldNotExist();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("sources", () => {
|
describe("sources", () => {
|
||||||
@@ -74,8 +91,27 @@ describe("modals", () => {
|
|||||||
await when.click("nav:sources");
|
await when.click("nav:sources");
|
||||||
});
|
});
|
||||||
|
|
||||||
test.skip("active sources", () => {});
|
test("active sources are listed and can be deleted", async () => {
|
||||||
test.skip("public source", () => {});
|
await when.setStyle("both");
|
||||||
|
await when.click("nav:sources");
|
||||||
|
const before = Object.keys(get.fixture("geojson-raster-style.json").sources).length;
|
||||||
|
await when.modal.deleteFirstActiveSource();
|
||||||
|
await then(
|
||||||
|
get.styleFromLocalStorage().then((style) => Object.keys(style.sources).length)
|
||||||
|
).shouldEqual(before - 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("public source", async () => {
|
||||||
|
await when.modal.addPublicSource();
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
sources: {
|
||||||
|
openmaptiles: {
|
||||||
|
type: "vector",
|
||||||
|
url: `https://api.maptiler.com/tiles/v3-openmaptiles/tiles.json?key=${tokens.openmaptiles}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("add new source", async () => {
|
test("add new source", async () => {
|
||||||
const sourceId = "n1z2v3r";
|
const sourceId = "n1z2v3r";
|
||||||
@@ -84,8 +120,8 @@ describe("modals", () => {
|
|||||||
await when.select("modal:sources.add.scheme_type", "tms");
|
await when.select("modal:sources.add.scheme_type", "tms");
|
||||||
await when.click("modal:sources.add.add_source");
|
await when.click("modal:sources.add.add_source");
|
||||||
await when.wait(200);
|
await when.wait(200);
|
||||||
await then(get.styleFromLocalStorage().then((style) => style.sources[sourceId])).shouldInclude({
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
scheme: "tms",
|
sources: { [sourceId]: { scheme: "tms" } },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -118,8 +154,110 @@ describe("modals", () => {
|
|||||||
await when.setValue("modal:sources.add.tile_size", "128");
|
await when.setValue("modal:sources.add.tile_size", "128");
|
||||||
await when.click("modal:sources.add.add_source");
|
await when.click("modal:sources.add.add_source");
|
||||||
await when.wait(200);
|
await when.wait(200);
|
||||||
await then(get.styleFromLocalStorage().then((style) => style.sources[sourceId])).shouldInclude({
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
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]] },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -204,8 +342,8 @@ describe("modals", () => {
|
|||||||
const apiKey = "testing123";
|
const apiKey = "testing123";
|
||||||
await when.setValue("modal:settings.maputnik:openmaptiles_access_token", apiKey);
|
await when.setValue("modal:settings.maputnik:openmaptiles_access_token", apiKey);
|
||||||
await when.click("modal:settings.name");
|
await when.click("modal:settings.name");
|
||||||
await then(get.styleFromLocalStorage().then((style) => style.metadata)).shouldInclude({
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
"maputnik:openmaptiles_access_token": apiKey,
|
metadata: { "maputnik:openmaptiles_access_token": apiKey },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -213,8 +351,8 @@ describe("modals", () => {
|
|||||||
const apiKey = "testing123";
|
const apiKey = "testing123";
|
||||||
await when.setValue("modal:settings.maputnik:thunderforest_access_token", apiKey);
|
await when.setValue("modal:settings.maputnik:thunderforest_access_token", apiKey);
|
||||||
await when.click("modal:settings.name");
|
await when.click("modal:settings.name");
|
||||||
await then(get.styleFromLocalStorage().then((style) => style.metadata)).shouldInclude({
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
"maputnik:thunderforest_access_token": apiKey,
|
metadata: { "maputnik:thunderforest_access_token": apiKey },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -222,8 +360,8 @@ describe("modals", () => {
|
|||||||
const apiKey = "testing123";
|
const apiKey = "testing123";
|
||||||
await when.setValue("modal:settings.maputnik:stadia_access_token", apiKey);
|
await when.setValue("modal:settings.maputnik:stadia_access_token", apiKey);
|
||||||
await when.click("modal:settings.name");
|
await when.click("modal:settings.name");
|
||||||
await then(get.styleFromLocalStorage().then((style) => style.metadata)).shouldInclude({
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
"maputnik:stadia_access_token": apiKey,
|
metadata: { "maputnik:stadia_access_token": apiKey },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -231,29 +369,67 @@ describe("modals", () => {
|
|||||||
const apiKey = "testing123";
|
const apiKey = "testing123";
|
||||||
await when.setValue("modal:settings.maputnik:locationiq_access_token", apiKey);
|
await when.setValue("modal:settings.maputnik:locationiq_access_token", apiKey);
|
||||||
await when.click("modal:settings.name");
|
await when.click("modal:settings.name");
|
||||||
await then(get.styleFromLocalStorage().then((style) => style.metadata)).shouldInclude({
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
"maputnik:locationiq_access_token": apiKey,
|
metadata: { "maputnik:locationiq_access_token": apiKey },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("map view defaults", async () => {
|
||||||
|
await when.setValue("modal:settings.zoom", "4");
|
||||||
|
await when.setValue("modal:settings.bearing", "12");
|
||||||
|
await when.setValue("modal:settings.pitch", "30");
|
||||||
|
await when.click("modal:settings.name");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
zoom: 4,
|
||||||
|
bearing: 12,
|
||||||
|
pitch: 30,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("light intensity", async () => {
|
||||||
|
await when.setValue("modal:settings.light-intensity", "0.7");
|
||||||
|
await when.click("modal:settings.name");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
light: { intensity: 0.7 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("terrain source and exaggeration", async () => {
|
||||||
|
await when.setValue("modal:settings.maputnik:terrain_source", "terrain");
|
||||||
|
await when.setValue("modal:settings.terrain-exaggeration", "1.5");
|
||||||
|
await when.click("modal:settings.name");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
terrain: { source: "terrain", exaggeration: 1.5 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("transition delay and duration", async () => {
|
||||||
|
await when.setValue("modal:settings.transition-delay", "100");
|
||||||
|
await when.setValue("modal:settings.transition-duration", "500");
|
||||||
|
await when.click("modal:settings.name");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
transition: { delay: 100, duration: 500 },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("style projection mercator", async () => {
|
test("style projection mercator", async () => {
|
||||||
await when.select("modal:settings.projection", "mercator");
|
await when.select("modal:settings.projection", "mercator");
|
||||||
await then(get.styleFromLocalStorage().then((style) => style.projection)).shouldInclude({
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
type: "mercator",
|
projection: { type: "mercator" },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("style projection globe", async () => {
|
test("style projection globe", async () => {
|
||||||
await when.select("modal:settings.projection", "globe");
|
await when.select("modal:settings.projection", "globe");
|
||||||
await then(get.styleFromLocalStorage().then((style) => style.projection)).shouldInclude({
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
type: "globe",
|
projection: { type: "globe" },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("style projection vertical-perspective", async () => {
|
test("style projection vertical-perspective", async () => {
|
||||||
await when.select("modal:settings.projection", "vertical-perspective");
|
await when.select("modal:settings.projection", "vertical-perspective");
|
||||||
await then(get.styleFromLocalStorage().then((style) => style.projection)).shouldInclude({
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
type: "vertical-perspective",
|
projection: { type: "vertical-perspective" },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -311,10 +487,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");
|
||||||
|
|||||||
+61
-22
@@ -62,11 +62,12 @@ function isLocator(target: unknown): target is Locator {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Asserts that every top-level key in `expected` deep-equals its counterpart in `actual`. */
|
/**
|
||||||
function assertDeepNestedInclude(actual: any, expected: Record<string, unknown>): void {
|
* Asserts that `actual` recursively contains everything in `expected`: nested
|
||||||
for (const key of Object.keys(expected)) {
|
* objects are matched as subsets, while arrays and primitives must match exactly.
|
||||||
expect(actual?.[key], `property "${key}"`).toEqual(expected[key]);
|
*/
|
||||||
}
|
function assertDeepNestedInclude(actual: any, expected: Record<string, unknown> | unknown[]): void {
|
||||||
|
expect(actual).toMatchObject(expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -130,7 +131,7 @@ export class Assertable<T> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
shouldDeepNestedInclude = (value: Record<string, unknown>) =>
|
shouldDeepNestedInclude = (value: Record<string, unknown> | unknown[]) =>
|
||||||
this.assertValue((actual) => assertDeepNestedInclude(actual, value));
|
this.assertValue((actual) => assertDeepNestedInclude(actual, value));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,6 +145,8 @@ 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",
|
||||||
};
|
};
|
||||||
|
|
||||||
for (let i = 0; i < tokens.length; i++) {
|
for (let i = 0; i < tokens.length; i++) {
|
||||||
@@ -202,10 +205,31 @@ export class PlaywrightHelper {
|
|||||||
return new Query<T>(getter);
|
return new Query<T>(getter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Stubs the File System Access "save" picker so file saves complete headlessly. */
|
||||||
|
public stubSaveFilePicker(): Promise<void> {
|
||||||
|
return this.page.evaluate(() => {
|
||||||
|
(window as any).showSaveFilePicker = async () => ({
|
||||||
|
createWritable: async () => ({ write: async () => {}, close: async () => {} }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Entry point for fluent assertions over a Locator or a value/Query. */
|
/** Entry point for fluent assertions over a Locator or a value/Query. */
|
||||||
public then = <T>(target: T): Assertable<T> => new Assertable(target);
|
public then = <T>(target: T): Assertable<T> => new Assertable(target);
|
||||||
|
|
||||||
public given = {
|
public given = {
|
||||||
|
/**
|
||||||
|
* Removes the File System Access API so the app falls back to a plain
|
||||||
|
* <input type="file">, the way Firefox and Safari behave. Must be called
|
||||||
|
* before the page under test is loaded.
|
||||||
|
*/
|
||||||
|
noFileSystemAccessApi: async () => {
|
||||||
|
await this.page.addInitScript(() => {
|
||||||
|
delete (window as any).showOpenFilePicker;
|
||||||
|
delete (window as any).showSaveFilePicker;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
intercept: async (pattern: RegExp, alias: string, _method = "GET") => {
|
intercept: async (pattern: RegExp, alias: string, _method = "GET") => {
|
||||||
this.recordedRequests.set(alias, []);
|
this.recordedRequests.set(alias, []);
|
||||||
await this.page.route(pattern, (route) => {
|
await this.page.route(pattern, (route) => {
|
||||||
@@ -333,23 +357,38 @@ export class PlaywrightHelper {
|
|||||||
await this.page.mouse.up();
|
await this.page.mouse.up();
|
||||||
},
|
},
|
||||||
|
|
||||||
openFileByFixture: async (fixture: string, buttonTestId: string, inputTestId: string) => {
|
/**
|
||||||
|
* Opens a fixture through the File System Access API, which raises no
|
||||||
|
* "filechooser" event and so has to be stubbed. For the <input type="file">
|
||||||
|
* fallback that browsers without the API use, see chooseFileFromPicker.
|
||||||
|
*/
|
||||||
|
openFileByFixture: async (fixture: string, buttonTestId: string) => {
|
||||||
const content = JSON.stringify(this.readFixture(fixture));
|
const content = JSON.stringify(this.readFixture(fixture));
|
||||||
const hasPicker = await this.page.evaluate(() => "showOpenFilePicker" in window);
|
await this.page.evaluate((fileContent) => {
|
||||||
if (hasPicker) {
|
(window as any).showOpenFilePicker = async () => [
|
||||||
await this.page.evaluate((fileContent) => {
|
{ getFile: async () => ({ text: async () => fileContent }) },
|
||||||
(window as any).showOpenFilePicker = async () => [
|
];
|
||||||
{ getFile: async () => ({ text: async () => fileContent }) },
|
}, content);
|
||||||
];
|
await this.testId(buttonTestId).click();
|
||||||
}, content);
|
},
|
||||||
await this.testId(buttonTestId).click();
|
|
||||||
} else {
|
/**
|
||||||
await this.testId(inputTestId).setInputFiles({
|
* Clicks a control that opens the browser's native file chooser and answers
|
||||||
name: fixture,
|
* it with a fixture. Only works on the <input type="file"> path — the File
|
||||||
mimeType: "application/json",
|
* System Access API does not raise a "filechooser" event, so pair this with
|
||||||
buffer: Buffer.from(content),
|
* given.noFileSystemAccessApi().
|
||||||
});
|
*/
|
||||||
}
|
chooseFileFromPicker: async (fixture: string, triggerTestId: string) => {
|
||||||
|
const content = JSON.stringify(this.readFixture(fixture));
|
||||||
|
const [fileChooser] = await Promise.all([
|
||||||
|
this.page.waitForEvent("filechooser"),
|
||||||
|
this.testId(triggerTestId).click(),
|
||||||
|
]);
|
||||||
|
await fileChooser.setFiles({
|
||||||
|
name: fixture,
|
||||||
|
mimeType: "application/json",
|
||||||
|
buffer: Buffer.from(content),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
dropFileByFixture: async (fixture: string, dropzoneTestId: string) => {
|
dropFileByFixture: async (fixture: string, dropzoneTestId: string) => {
|
||||||
|
|||||||
Generated
+29
-175
@@ -90,8 +90,6 @@
|
|||||||
"@types/string-hash": "^1.1.3",
|
"@types/string-hash": "^1.1.3",
|
||||||
"@types/wicg-file-system-access": "^2023.10.7",
|
"@types/wicg-file-system-access": "^2023.10.7",
|
||||||
"@vitejs/plugin-react": "5.2",
|
"@vitejs/plugin-react": "5.2",
|
||||||
"@vitest/browser": "^4.1.10",
|
|
||||||
"@vitest/browser-playwright": "^4.1.10",
|
|
||||||
"@vitest/coverage-v8": "^4.1.10",
|
"@vitest/coverage-v8": "^4.1.10",
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"eslint": "^10.6.0",
|
"eslint": "^10.6.0",
|
||||||
@@ -113,8 +111,7 @@
|
|||||||
"uuid": "^14.0.1",
|
"uuid": "^14.0.1",
|
||||||
"vite": "^7.3.2",
|
"vite": "^7.3.2",
|
||||||
"vite-plugin-istanbul": "^9.0.1",
|
"vite-plugin-istanbul": "^9.0.1",
|
||||||
"vitest": "^4.1.10",
|
"vitest": "^4.1.10"
|
||||||
"vitest-browser-react": "^2.2.0"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
@@ -438,13 +435,6 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@blazediff/core": {
|
|
||||||
"version": "1.9.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@blazediff/core/-/core-1.9.1.tgz",
|
|
||||||
"integrity": "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@cacheable/memory": {
|
"node_modules/@cacheable/memory": {
|
||||||
"version": "2.0.9",
|
"version": "2.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.0.9.tgz",
|
||||||
@@ -2631,13 +2621,6 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@polka/url": {
|
|
||||||
"version": "1.0.0-next.29",
|
|
||||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
|
||||||
"integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-rc.3",
|
"version": "1.0.0-rc.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
|
||||||
@@ -4066,53 +4049,6 @@
|
|||||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
|
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/browser": {
|
|
||||||
"version": "4.1.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.10.tgz",
|
|
||||||
"integrity": "sha512-UDwuWGwXj646CBx/bQHOaJSX7np0I8JL/UKQYa1e4QrVHH8VdWtx8eaOuf8sy0ShwDgR6NjJAsp5eF6vjF6qng==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@blazediff/core": "1.9.1",
|
|
||||||
"@vitest/mocker": "4.1.10",
|
|
||||||
"@vitest/utils": "4.1.10",
|
|
||||||
"magic-string": "^0.30.21",
|
|
||||||
"pngjs": "^7.0.0",
|
|
||||||
"sirv": "^3.0.2",
|
|
||||||
"tinyrainbow": "^3.1.0",
|
|
||||||
"ws": "^8.19.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/vitest"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"vitest": "4.1.10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@vitest/browser-playwright": {
|
|
||||||
"version": "4.1.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.1.10.tgz",
|
|
||||||
"integrity": "sha512-nMoXGEiRpT7m3W7NsbvrM2aKNwiNHZf+zEpUCvMteGjZFvfT96Q9fh7QyB98dvDWXiKvrLxA7bJ1mCOOv+JQPw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@vitest/browser": "4.1.10",
|
|
||||||
"@vitest/mocker": "4.1.10",
|
|
||||||
"tinyrainbow": "^3.1.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/vitest"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"playwright": "*",
|
|
||||||
"vitest": "4.1.10"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"playwright": {
|
|
||||||
"optional": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@vitest/coverage-v8": {
|
"node_modules/@vitest/coverage-v8": {
|
||||||
"version": "4.1.10",
|
"version": "4.1.10",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz",
|
||||||
@@ -8387,9 +8323,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/istanbul-lib-processinfo": {
|
"node_modules/istanbul-lib-processinfo": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/istanbul-lib-processinfo/-/istanbul-lib-processinfo-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/istanbul-lib-processinfo/-/istanbul-lib-processinfo-3.0.0.tgz",
|
||||||
"integrity": "sha512-s3mX05h5wGZeScG6XnOanygPh4SJu5ujMc9YbvpnLGXWy1cRiGbp0NdVcjHxgoZt3WfQppfBsa0y+gWdYJ2pGQ==",
|
"integrity": "sha512-P7nLXRRlo7Sqinty6lNa7+4o9jBUYGpqtejqCOZKfgXlRoxY/QArflcB86YO500Ahj4pDJEG34JjMRbQgePLnQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -8397,7 +8333,8 @@
|
|||||||
"cross-spawn": "^7.0.3",
|
"cross-spawn": "^7.0.3",
|
||||||
"istanbul-lib-coverage": "^3.2.0",
|
"istanbul-lib-coverage": "^3.2.0",
|
||||||
"p-map": "^3.0.0",
|
"p-map": "^3.0.0",
|
||||||
"rimraf": "^6.1.3"
|
"rimraf": "^6.1.3",
|
||||||
|
"uuid": "^8.3.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "20 || >=22"
|
"node": "20 || >=22"
|
||||||
@@ -8416,6 +8353,16 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/istanbul-lib-processinfo/node_modules/uuid": {
|
||||||
|
"version": "8.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||||
|
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/istanbul-lib-report": {
|
"node_modules/istanbul-lib-report": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
|
||||||
@@ -9756,16 +9703,6 @@
|
|||||||
"mkdirp": "bin/cmd.js"
|
"mkdirp": "bin/cmd.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mrmime": {
|
|
||||||
"version": "2.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
|
|
||||||
"integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
@@ -10807,16 +10744,6 @@
|
|||||||
"fflate": "^0.8.2"
|
"fflate": "^0.8.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pngjs": {
|
|
||||||
"version": "7.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
|
|
||||||
"integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.19.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/possible-typed-array-names": {
|
"node_modules/possible-typed-array-names": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||||
@@ -11064,13 +10991,12 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.15.3",
|
"version": "6.14.2",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
||||||
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
|
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-define-property": "^1.0.1",
|
"side-channel": "^1.1.0"
|
||||||
"side-channel": "^1.1.1"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.6"
|
"node": ">=0.6"
|
||||||
@@ -11938,14 +11864,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/side-channel": {
|
"node_modules/side-channel": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||||
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
|
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
"object-inspect": "^1.13.4",
|
"object-inspect": "^1.13.3",
|
||||||
"side-channel-list": "^1.0.1",
|
"side-channel-list": "^1.0.0",
|
||||||
"side-channel-map": "^1.0.1",
|
"side-channel-map": "^1.0.1",
|
||||||
"side-channel-weakmap": "^1.0.2"
|
"side-channel-weakmap": "^1.0.2"
|
||||||
},
|
},
|
||||||
@@ -11957,13 +11883,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/side-channel-list": {
|
"node_modules/side-channel-list": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
||||||
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
|
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
"object-inspect": "^1.13.4"
|
"object-inspect": "^1.13.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -12023,21 +11949,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/sirv": {
|
|
||||||
"version": "3.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
|
|
||||||
"integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@polka/url": "^1.0.0-next.24",
|
|
||||||
"mrmime": "^2.0.0",
|
|
||||||
"totalist": "^3.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/slugify": {
|
"node_modules/slugify": {
|
||||||
"version": "1.6.9",
|
"version": "1.6.9",
|
||||||
"resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.9.tgz",
|
"resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.9.tgz",
|
||||||
@@ -12909,16 +12820,6 @@
|
|||||||
"node": ">=8.0"
|
"node": ">=8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/totalist": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/trim-lines": {
|
"node_modules/trim-lines": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
|
||||||
@@ -13669,31 +13570,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vitest-browser-react": {
|
|
||||||
"version": "2.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/vitest-browser-react/-/vitest-browser-react-2.2.0.tgz",
|
|
||||||
"integrity": "sha512-oY3KM6305kwJMa6nHo92vVtkOsih7mjEf12dLKuphaF+9ywWPEc+qanIBd394SZ6m5LadVEaG6dicvvizOzmjA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/vitest"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "^18.0.0 || ^19.0.0",
|
|
||||||
"@types/react-dom": "^18.0.0 || ^19.0.0",
|
|
||||||
"react": "^18.0.0 || ^19.0.0",
|
|
||||||
"react-dom": "^18.0.0 || ^19.0.0",
|
|
||||||
"vitest": "^4.0.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@types/react-dom": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/void-elements": {
|
"node_modules/void-elements": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||||
@@ -13881,28 +13757,6 @@
|
|||||||
"typedarray-to-buffer": "^3.1.5"
|
"typedarray-to-buffer": "^3.1.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ws": {
|
|
||||||
"version": "8.21.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
|
||||||
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"bufferutil": "^4.0.1",
|
|
||||||
"utf-8-validate": ">=5.0.2"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"bufferutil": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"utf-8-validate": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/xml-utils": {
|
"node_modules/xml-utils": {
|
||||||
"version": "1.10.2",
|
"version": "1.10.2",
|
||||||
"resolved": "https://registry.npmjs.org/xml-utils/-/xml-utils-1.10.2.tgz",
|
"resolved": "https://registry.npmjs.org/xml-utils/-/xml-utils-1.10.2.tgz",
|
||||||
|
|||||||
+1
-4
@@ -124,8 +124,6 @@
|
|||||||
"@types/string-hash": "^1.1.3",
|
"@types/string-hash": "^1.1.3",
|
||||||
"@types/wicg-file-system-access": "^2023.10.7",
|
"@types/wicg-file-system-access": "^2023.10.7",
|
||||||
"@vitejs/plugin-react": "5.2",
|
"@vitejs/plugin-react": "5.2",
|
||||||
"@vitest/browser": "^4.1.10",
|
|
||||||
"@vitest/browser-playwright": "^4.1.10",
|
|
||||||
"@vitest/coverage-v8": "^4.1.10",
|
"@vitest/coverage-v8": "^4.1.10",
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"eslint": "^10.6.0",
|
"eslint": "^10.6.0",
|
||||||
@@ -147,7 +145,6 @@
|
|||||||
"uuid": "^14.0.1",
|
"uuid": "^14.0.1",
|
||||||
"vite": "^7.3.2",
|
"vite": "^7.3.2",
|
||||||
"vite-plugin-istanbul": "^9.0.1",
|
"vite-plugin-istanbul": "^9.0.1",
|
||||||
"vitest": "^4.1.10",
|
"vitest": "^4.1.10"
|
||||||
"vitest-browser-react": "^2.2.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
import { expect, test } from "vitest";
|
|
||||||
import { render } from "vitest-browser-react";
|
|
||||||
import { page } from "vitest/browser";
|
|
||||||
import { InputAutocomplete } from "./InputAutocomplete";
|
|
||||||
|
|
||||||
const fruits = ["apple", "banana", "cherry"];
|
|
||||||
|
|
||||||
test("filters options when typing", async () => {
|
|
||||||
render(<InputAutocomplete aria-label="Fruit" options={fruits.map((f) => [f, f])} />);
|
|
||||||
|
|
||||||
const input = page.getByLabelText("Fruit");
|
|
||||||
await input.click();
|
|
||||||
|
|
||||||
const menuItems = page.getByRole("option");
|
|
||||||
await expect.element(menuItems.first()).toBeVisible();
|
|
||||||
expect(menuItems.all()).toHaveLength(3);
|
|
||||||
|
|
||||||
await input.fill("ch");
|
|
||||||
await expect.element(page.getByText("cherry")).toBeVisible();
|
|
||||||
expect(page.getByRole("option").all()).toHaveLength(1);
|
|
||||||
|
|
||||||
await page.getByText("cherry").click();
|
|
||||||
await expect.element(input).toHaveValue("cherry");
|
|
||||||
});
|
|
||||||
@@ -230,6 +230,7 @@ class LayerEditorInternal extends React.Component<LayerEditorInternalProps, Laye
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{this.props.layer.type !== "background" && <FieldSource
|
{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}
|
||||||
|
|||||||
@@ -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" }} />
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import type { LayerSpecification } from "maplibre-gl";
|
||||||
|
import { colorHighlightedLayer } from "./highlight";
|
||||||
|
|
||||||
|
function layer(overrides: Record<string, any>): LayerSpecification {
|
||||||
|
return { id: "l", source: "s", "source-layer": "sl", ...overrides } as unknown as LayerSpecification;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("colorHighlightedLayer", () => {
|
||||||
|
it("returns null for undefined, background and raster layers", () => {
|
||||||
|
expect(colorHighlightedLayer(undefined)).toBeNull();
|
||||||
|
expect(colorHighlightedLayer(layer({ type: "background" }))).toBeNull();
|
||||||
|
expect(colorHighlightedLayer(layer({ type: "raster" }))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds a circle highlight for circle and symbol layers", () => {
|
||||||
|
for (const type of ["circle", "symbol"]) {
|
||||||
|
const highlight = colorHighlightedLayer(layer({ type }))!;
|
||||||
|
expect(highlight).not.toBeNull();
|
||||||
|
expect(highlight.type).toBe("circle");
|
||||||
|
expect(highlight.id).toMatch(/_highlight$/);
|
||||||
|
expect((highlight.paint as any)["circle-radius"]).toBe(3);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds a line highlight with an overridden width", () => {
|
||||||
|
const highlight = colorHighlightedLayer(layer({ type: "line" }))!;
|
||||||
|
expect(highlight.type).toBe("line");
|
||||||
|
expect((highlight.paint as any)["line-width"]).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds a polygon highlight for fill and fill-extrusion layers", () => {
|
||||||
|
for (const type of ["fill", "fill-extrusion"]) {
|
||||||
|
const highlight = colorHighlightedLayer(layer({ type }))!;
|
||||||
|
expect(highlight.type).toBe("fill");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copies the source layer's filter when present, drops it otherwise", () => {
|
||||||
|
const withFilter = colorHighlightedLayer(
|
||||||
|
layer({ type: "line", filter: ["==", "class", "road"] } as any)
|
||||||
|
)!;
|
||||||
|
expect(withFilter.filter).toEqual(["==", "class", "road"]);
|
||||||
|
|
||||||
|
const withoutFilter = colorHighlightedLayer(layer({ type: "line" }))!;
|
||||||
|
expect("filter" in withoutFilter).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import type { LayerSpecification } from "maplibre-gl";
|
||||||
|
import { changeType, changeProperty, layerPrefix, findClosestCommonPrefix } from "./layer";
|
||||||
|
|
||||||
|
describe("changeType", () => {
|
||||||
|
it("drops paint/layout props not valid for the new type", () => {
|
||||||
|
const layer = {
|
||||||
|
id: "l",
|
||||||
|
type: "fill",
|
||||||
|
source: "s",
|
||||||
|
paint: { "fill-color": "#fff", "fill-opacity": 0.5 },
|
||||||
|
layout: { visibility: "visible" },
|
||||||
|
} as unknown as LayerSpecification;
|
||||||
|
|
||||||
|
const changed = changeType(layer, "background") as any;
|
||||||
|
expect(changed.type).toBe("background");
|
||||||
|
// fill-color is not a valid background paint property
|
||||||
|
expect(changed.paint["fill-color"]).toBeUndefined();
|
||||||
|
// visibility is valid for background layout
|
||||||
|
expect(changed.layout.visibility).toBe("visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps props that are valid for the new type", () => {
|
||||||
|
const layer = {
|
||||||
|
id: "l",
|
||||||
|
type: "line",
|
||||||
|
source: "s",
|
||||||
|
paint: { "line-color": "#000" },
|
||||||
|
} as unknown as LayerSpecification;
|
||||||
|
const changed = changeType(layer, "line") as any;
|
||||||
|
expect(changed.paint["line-color"]).toBe("#000");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("changeProperty", () => {
|
||||||
|
const base = { id: "l", type: "background" } as unknown as LayerSpecification;
|
||||||
|
|
||||||
|
it("sets a top-level property", () => {
|
||||||
|
const changed = changeProperty(base, null, "minzoom", 4) as any;
|
||||||
|
expect(changed.minzoom).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes a top-level property when value is undefined", () => {
|
||||||
|
const changed = changeProperty({ ...base, minzoom: 4 } as any, null, "minzoom", undefined) as any;
|
||||||
|
expect("minzoom" in changed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets a grouped property", () => {
|
||||||
|
const changed = changeProperty(base, "paint", "background-color", "#fff") as any;
|
||||||
|
expect(changed.paint["background-color"]).toBe("#fff");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes a grouped property and drops the empty group", () => {
|
||||||
|
const layer = { ...base, paint: { "background-color": "#fff" } } as any;
|
||||||
|
const changed = changeProperty(layer, "paint", "background-color", undefined) as any;
|
||||||
|
expect("paint" in changed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes a grouped property but keeps a non-empty group", () => {
|
||||||
|
const layer = { ...base, paint: { "background-color": "#fff", "background-opacity": 1 } } as any;
|
||||||
|
const changed = changeProperty(layer, "paint", "background-color", undefined) as any;
|
||||||
|
expect(changed.paint["background-color"]).toBeUndefined();
|
||||||
|
expect(changed.paint["background-opacity"]).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("layerPrefix", () => {
|
||||||
|
it("returns the segment before the first separator", () => {
|
||||||
|
expect(layerPrefix("foo-bar")).toBe("foo");
|
||||||
|
expect(layerPrefix("foo_bar")).toBe("foo");
|
||||||
|
expect(layerPrefix("foo bar")).toBe("foo");
|
||||||
|
expect(layerPrefix("single")).toBe("single");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findClosestCommonPrefix", () => {
|
||||||
|
const layers = [
|
||||||
|
{ id: "aa" },
|
||||||
|
{ id: "aa-2" },
|
||||||
|
{ id: "b" },
|
||||||
|
] as unknown as LayerSpecification[];
|
||||||
|
|
||||||
|
it("finds the first index of a run sharing the prefix", () => {
|
||||||
|
expect(findClosestCommonPrefix(layers, 1)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the index itself when the previous prefix differs", () => {
|
||||||
|
expect(findClosestCommonPrefix(layers, 2)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the index for the first layer", () => {
|
||||||
|
expect(findClosestCommonPrefix(layers, 0)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { LayerWatcher } from "./layerwatcher";
|
||||||
|
import type { Map } from "maplibre-gl";
|
||||||
|
|
||||||
|
function mockMap(): Map {
|
||||||
|
return {
|
||||||
|
style: {
|
||||||
|
tileManagers: {
|
||||||
|
vector: { _source: { vectorLayerIds: ["water", "roads"] } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
querySourceFeatures: () => [
|
||||||
|
{ properties: { class: "river", name: "A" } },
|
||||||
|
{ properties: { class: "canal" } },
|
||||||
|
],
|
||||||
|
} as unknown as Map;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("LayerWatcher", () => {
|
||||||
|
it("reports sources discovered on the map", () => {
|
||||||
|
const onSourcesChange = vi.fn();
|
||||||
|
const watcher = new LayerWatcher({ onSourcesChange });
|
||||||
|
|
||||||
|
watcher.analyzeMap(mockMap());
|
||||||
|
expect(onSourcesChange).toHaveBeenCalledOnce();
|
||||||
|
expect(watcher.sources).toEqual({ vector: ["water", "roads"] });
|
||||||
|
|
||||||
|
// Re-analyzing the same sources does not fire the callback again.
|
||||||
|
watcher.analyzeMap(mockMap());
|
||||||
|
expect(onSourcesChange).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collects vector layer field values from source features", () => {
|
||||||
|
const onVectorLayersChange = vi.fn();
|
||||||
|
const watcher = new LayerWatcher({ onVectorLayersChange });
|
||||||
|
|
||||||
|
const map = mockMap();
|
||||||
|
watcher.analyzeMap(map); // populates _sources
|
||||||
|
watcher.analyzeVectorLayerFields(map);
|
||||||
|
|
||||||
|
expect(onVectorLayersChange).toHaveBeenCalled();
|
||||||
|
expect(watcher.vectorLayers.water.class).toHaveProperty("river");
|
||||||
|
expect(watcher.vectorLayers.water.class).toHaveProperty("canal");
|
||||||
|
expect(watcher.vectorLayers.water.name).toHaveProperty("A");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { sortNumerically } from "./sort-numerically";
|
||||||
|
|
||||||
|
describe("sortNumerically", () => {
|
||||||
|
it("orders numbers (and numeric strings) ascending", () => {
|
||||||
|
expect([3, 1, 2].sort(sortNumerically)).toEqual([1, 2, 3]);
|
||||||
|
expect(["10", "2", "1"].sort(sortNumerically)).toEqual(["1", "2", "10"]);
|
||||||
|
expect(sortNumerically(5, 5)).toBe(0);
|
||||||
|
expect(sortNumerically(1, 2)).toBe(-1);
|
||||||
|
expect(sortNumerically(2, 1)).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { findDefaultFromSpec } from "./spec-helper";
|
||||||
|
|
||||||
|
describe("findDefaultFromSpec", () => {
|
||||||
|
it("returns the spec default when present (even if falsy)", () => {
|
||||||
|
expect(findDefaultFromSpec({ type: "string", default: "hi" })).toBe("hi");
|
||||||
|
expect(findDefaultFromSpec({ type: "boolean", default: false })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to a sensible default per type", () => {
|
||||||
|
expect(findDefaultFromSpec({ type: "color" })).toBe("#000000");
|
||||||
|
expect(findDefaultFromSpec({ type: "string" })).toBe("");
|
||||||
|
// The falsy `false` fallback collapses to "" via the `|| ""` in the impl.
|
||||||
|
expect(findDefaultFromSpec({ type: "boolean" })).toBe("");
|
||||||
|
expect(findDefaultFromSpec({ type: "array" })).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.stubGlobal("window", { location: { port: "8000" } });
|
||||||
|
|
||||||
|
// Avoid opening a real websocket in notifyLocalChanges().
|
||||||
|
const wsInstances: any[] = [];
|
||||||
|
vi.mock("reconnecting-websocket", () => ({
|
||||||
|
default: class {
|
||||||
|
onmessage: ((e: { data: string }) => void) | null = null;
|
||||||
|
constructor(public url: string) {
|
||||||
|
wsInstances.push(this);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ApiStyleStore } from "./apistore";
|
||||||
|
|
||||||
|
const okJson = (body: any) => ({ ok: true, json: async () => body });
|
||||||
|
|
||||||
|
describe("ApiStyleStore", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
wsInstances.length = 0;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("init fetches style ids and wires up local change notifications", async () => {
|
||||||
|
const fetchMock = vi.fn(async () => okJson(["style-a", "style-b"]) as any);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
const onLocalStyleChange = vi.fn();
|
||||||
|
const store = new ApiStyleStore({ onLocalStyleChange });
|
||||||
|
|
||||||
|
await store.init();
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith("http://localhost:8000/styles", { mode: "cors" });
|
||||||
|
expect(store.latestStyleId).toBe("style-a");
|
||||||
|
|
||||||
|
// A websocket message triggers onLocalStyleChange with a valid style.
|
||||||
|
expect(wsInstances.length).toBe(1);
|
||||||
|
wsInstances[0].onmessage!({ data: JSON.stringify({ version: 8, sources: {}, layers: [] }) });
|
||||||
|
expect(onLocalStyleChange).toHaveBeenCalledOnce();
|
||||||
|
expect(onLocalStyleChange.mock.calls[0][0].id).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("init throws a friendly error when the API is unreachable", async () => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("boom"); }));
|
||||||
|
const store = new ApiStyleStore({});
|
||||||
|
await expect(store.init()).rejects.toThrow("Can not connect to style API");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getLatestStyle fetches the latest style and validates it", async () => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn(async () => okJson(["s1"]) as any));
|
||||||
|
const store = new ApiStyleStore({});
|
||||||
|
await store.init();
|
||||||
|
|
||||||
|
vi.stubGlobal("fetch", vi.fn(async () => okJson({ version: 8, id: "s1", sources: {}, layers: [] }) as any));
|
||||||
|
const latest = await store.getLatestStyle();
|
||||||
|
expect(latest.id).toBe("s1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getLatestStyle throws when not initialised", async () => {
|
||||||
|
const store = new ApiStyleStore({});
|
||||||
|
await expect(store.getLatestStyle()).rejects.toThrow(/init the api backend/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("save PUTs the (token-stripped) style to the API", () => {
|
||||||
|
const fetchMock = vi.fn(async () => okJson({}) as any);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
const store = new ApiStyleStore({});
|
||||||
|
const mapStyle = { version: 8, id: "s1", sources: {}, layers: [] } as any;
|
||||||
|
|
||||||
|
expect(store.save(mapStyle)).toBe(mapStyle);
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"http://localhost:8000/styles/s1",
|
||||||
|
expect.objectContaining({ method: "PUT", mode: "cors" })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { StyleStore } from "./stylestore";
|
||||||
|
|
||||||
|
class LocalStorageMock {
|
||||||
|
private store: Record<string, string> = {};
|
||||||
|
get length() {
|
||||||
|
return Object.keys(this.store).length;
|
||||||
|
}
|
||||||
|
key(i: number) {
|
||||||
|
return Object.keys(this.store)[i] ?? null;
|
||||||
|
}
|
||||||
|
getItem(k: string) {
|
||||||
|
return k in this.store ? this.store[k] : null;
|
||||||
|
}
|
||||||
|
setItem(k: string, v: string) {
|
||||||
|
this.store[k] = v;
|
||||||
|
}
|
||||||
|
removeItem(k: string) {
|
||||||
|
delete this.store[k];
|
||||||
|
}
|
||||||
|
clear() {
|
||||||
|
this.store = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vi.stubGlobal("window", { localStorage: new LocalStorageMock() });
|
||||||
|
// loadDefaultStyle fetches the default style over the network; serve it locally.
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async () => ({
|
||||||
|
json: async () => ({ version: 8, id: "default", sources: {}, layers: [] }),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const style = (id: string) => ({ version: 8, id, sources: {}, layers: [] }) as any;
|
||||||
|
|
||||||
|
describe("StyleStore", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
window.localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the default style when nothing is stored", async () => {
|
||||||
|
const store = new StyleStore();
|
||||||
|
const latest = await store.getLatestStyle();
|
||||||
|
expect(latest.id).toBe("default");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves a style and reads it back as the latest", async () => {
|
||||||
|
const store = new StyleStore();
|
||||||
|
store.save(style("abc"));
|
||||||
|
// A fresh store discovers the persisted style ids.
|
||||||
|
const reopened = new StyleStore();
|
||||||
|
const latest = await reopened.getLatestStyle();
|
||||||
|
expect(latest.id).toBe("abc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("purge removes all maputnik keys", async () => {
|
||||||
|
const store = new StyleStore();
|
||||||
|
store.save(style("abc"));
|
||||||
|
window.localStorage.setItem("unrelated", "keep");
|
||||||
|
store.purge();
|
||||||
|
expect(window.localStorage.getItem("maputnik:style:abc")).toBeNull();
|
||||||
|
expect(window.localStorage.getItem("unrelated")).toBe("keep");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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`];
|
||||||
@@ -151,7 +142,6 @@ function stripAccessTokens(mapStyle: StyleSpecification) {
|
|||||||
export {
|
export {
|
||||||
ensureStyleValidity,
|
ensureStyleValidity,
|
||||||
emptyStyle,
|
emptyStyle,
|
||||||
indexOfLayer,
|
|
||||||
generateId,
|
generateId,
|
||||||
getAccessToken,
|
getAccessToken,
|
||||||
replaceAccessTokens,
|
replaceAccessTokens,
|
||||||
|
|||||||
+1
-27
@@ -1,34 +1,8 @@
|
|||||||
import { defineConfig } from "vitest/config";
|
import { defineConfig } from "vitest/config";
|
||||||
import react from "@vitejs/plugin-react";
|
|
||||||
import { playwright } from "@vitest/browser-playwright";
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
|
||||||
test: {
|
test: {
|
||||||
projects: [
|
include: ["src/**/*.test.{ts,tsx}"],
|
||||||
{
|
|
||||||
extends: true,
|
|
||||||
test: {
|
|
||||||
name: "unit",
|
|
||||||
environment: "node",
|
|
||||||
include: ["src/**/*.test.{ts,tsx}"],
|
|
||||||
exclude: ["src/**/*.browser.test.{ts,tsx}"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
extends: true,
|
|
||||||
test: {
|
|
||||||
name: "browser",
|
|
||||||
include: ["src/**/*.browser.test.{ts,tsx}"],
|
|
||||||
browser: {
|
|
||||||
enabled: true,
|
|
||||||
provider: playwright(),
|
|
||||||
headless: true,
|
|
||||||
instances: [{ browser: "chromium" }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
coverage: {
|
coverage: {
|
||||||
provider: "v8",
|
provider: "v8",
|
||||||
reporter: ["json", "lcov", "text-summary"],
|
reporter: ["json", "lcov", "text-summary"],
|
||||||
|
|||||||
Reference in New Issue
Block a user