mirror of
https://github.com/maputnik/editor.git
synced 2026-07-29 01:07:26 +00:00
Compare commits
78 Commits
v3.1.0
...
b09c629be4
| Author | SHA1 | Date | |
|---|---|---|---|
| b09c629be4 | |||
| 89be954485 | |||
| 83c099bef4 | |||
| 382ba69134 | |||
| dbe5d3916d | |||
| 373b57a6f4 | |||
| f2579e2a19 | |||
| aa9530274f | |||
| 0ba7b3704f | |||
| be11f75c9f | |||
| 03a41cf17a | |||
| 192df96a79 | |||
| 10d47c561b | |||
| c22f7404e7 | |||
| 36073b2b1f | |||
| ca66f47be0 | |||
| a5c47437ee | |||
| 91a6335d09 | |||
| acaa350f0f | |||
| 1a5c879c75 | |||
| b16776fed3 | |||
| 21e141542a | |||
| 320f94b707 | |||
| 2e0b7cdb8c | |||
| d039dc73a5 | |||
| c24049cb88 | |||
| 1047286d9e | |||
| 99b5dd785d | |||
| 70a2db580f | |||
| b76d9cbe29 | |||
| dd04c14d96 | |||
| 6c31add041 | |||
| 60ed80651c | |||
| c642ec5325 | |||
| 0c98378243 | |||
| 95ba3c4428 | |||
| b112c6a2bf | |||
| a251faf8f7 | |||
| 8580499ec3 | |||
| 56a9840def | |||
| 3d1919738b | |||
| cc317c20a1 | |||
| a9c3da0f40 | |||
| dc5d6dd77d | |||
| 95d87ad7ef | |||
| 09efbb5cd6 | |||
| 4f046cb83e | |||
| fb00dea285 | |||
| c00395ee11 | |||
| 5092a924aa | |||
| 7d80dccb81 | |||
| a93fe4d78b | |||
| 603a30cba8 | |||
| 6f55fda7d4 | |||
| 8f0ee898ff | |||
| be9456d11b | |||
| 3896c4c187 | |||
| c0f1a72d80 | |||
| ac9186e7f8 | |||
| 8c86607100 | |||
| 7c5e5358cb | |||
| 18b34d3ecd | |||
| 08ebca266c | |||
| 8344a30f5d | |||
| 4249e9beb9 | |||
| c3dd253737 | |||
| f7b48139a4 | |||
| 8af1cfd5f8 | |||
| a87a4d8df8 | |||
| 89d583715f | |||
| fadf01a51d | |||
| 251fe40fde | |||
| f175749fb7 | |||
| f9219c3c83 | |||
| f78b9ccaaa | |||
| bb73a2b1e8 | |||
| a4da75d722 | |||
| 1b314b11b9 |
+32
-21
@@ -56,7 +56,7 @@ jobs:
|
|||||||
|
|
||||||
# Build and upload desktop CLI artifacts
|
# Build and upload desktop CLI artifacts
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||||
with:
|
with:
|
||||||
go-version: ^1.23.x
|
go-version: ^1.23.x
|
||||||
cache-dependency-path: desktop/go.sum
|
cache-dependency-path: desktop/go.sum
|
||||||
@@ -98,7 +98,7 @@ jobs:
|
|||||||
- run: npm ci
|
- run: npm ci
|
||||||
- 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@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||||
with:
|
with:
|
||||||
files: ${{ github.workspace }}/coverage/coverage-final.json
|
files: ${{ github.workspace }}/coverage/coverage-final.json
|
||||||
verbose: true
|
verbose: true
|
||||||
@@ -117,17 +117,21 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version-file: '.nvmrc'
|
node-version-file: '.nvmrc'
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- name: Cypress run
|
- run: npx playwright install --with-deps chromium
|
||||||
uses: cypress-io/github-action@fa4a118725a8f001170d49631ea89e5d66fee626 # v7.4.1
|
- name: Playwright run
|
||||||
with:
|
run: npm run test-e2e
|
||||||
build: npm run build
|
|
||||||
start: npm run start
|
|
||||||
browser: chrome
|
|
||||||
- name: Upload coverage reports to Codecov
|
- name: Upload coverage reports to Codecov
|
||||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||||
with:
|
with:
|
||||||
files: ${{ github.workspace }}/.nyc_output/out.json
|
files: ${{ github.workspace }}/coverage/coverage-final.json
|
||||||
verbose: true
|
verbose: true
|
||||||
|
- name: Upload Playwright report
|
||||||
|
if: ${{ !cancelled() }}
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
|
with:
|
||||||
|
name: playwright-report
|
||||||
|
path: playwright-report/
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
e2e-tests-docker:
|
e2e-tests-docker:
|
||||||
name: "E2E tests using chrome and docker"
|
name: "E2E tests using chrome and docker"
|
||||||
@@ -141,14 +145,21 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version-file: '.nvmrc'
|
node-version-file: '.nvmrc'
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- name: Cypress run
|
- run: npx playwright install --with-deps chromium
|
||||||
uses: cypress-io/github-action@fa4a118725a8f001170d49631ea89e5d66fee626 # v7.4.1
|
- name: Build docker image
|
||||||
with:
|
run: docker build -t maputnik .
|
||||||
build: docker build -t maputnik .
|
- name: Start maputnik container
|
||||||
start: docker run --rm --network host maputnik --port=8888
|
run: docker run -d --network host --name maputnik maputnik --port=8888
|
||||||
browser: chrome
|
- name: Wait for maputnik to be ready
|
||||||
- name: Upload coverage reports to Codecov
|
run: |
|
||||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
for i in $(seq 1 60); do
|
||||||
with:
|
if curl -sSf http://localhost:8888/ > /dev/null; then
|
||||||
files: ${{ github.workspace }}/.nyc_output/out.json
|
echo "maputnik is up"; exit 0
|
||||||
verbose: true
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "maputnik did not start in time"; docker logs maputnik; exit 1
|
||||||
|
- name: Playwright run
|
||||||
|
run: npm run test-e2e
|
||||||
|
env:
|
||||||
|
E2E_NO_WEBSERVER: "1"
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ jobs:
|
|||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||||
@@ -53,7 +53,7 @@ jobs:
|
|||||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||||
# If this step fails, then you should remove it and run the build manually (see below)
|
# If this step fails, then you should remove it and run the build manually (see below)
|
||||||
- name: Autobuild
|
- name: Autobuild
|
||||||
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
uses: github/codeql-action/autobuild@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
|
||||||
|
|
||||||
# ℹ️ Command-line programs to run using the OS shell.
|
# ℹ️ Command-line programs to run using the OS shell.
|
||||||
# 📚 https://git.io/JvXDl
|
# 📚 https://git.io/JvXDl
|
||||||
@@ -67,4 +67,4 @@ jobs:
|
|||||||
# make release
|
# make release
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ jobs:
|
|||||||
registry-url: "https://registry.npmjs.org"
|
registry-url: "https://registry.npmjs.org"
|
||||||
|
|
||||||
- name: Set up Go for desktop build
|
- name: Set up Go for desktop build
|
||||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||||
with:
|
with:
|
||||||
go-version: ^1.23.x
|
go-version: ^1.23.x
|
||||||
cache-dependency-path: desktop/go.sum
|
cache-dependency-path: desktop/go.sum
|
||||||
|
|||||||
+5
-1
@@ -33,10 +33,14 @@ node_modules
|
|||||||
public
|
public
|
||||||
/errorShots
|
/errorShots
|
||||||
/old
|
/old
|
||||||
/cypress/screenshots
|
|
||||||
/dist/
|
/dist/
|
||||||
/desktop/version.go
|
/desktop/version.go
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
/test-results/
|
||||||
|
/playwright-report/
|
||||||
|
/playwright/.cache/
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
|||||||
+5
-4
@@ -4,12 +4,13 @@
|
|||||||
"check-coverage": false,
|
"check-coverage": false,
|
||||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"cypress/**/*.*",
|
"e2e/**/*.*",
|
||||||
"**/*.d.ts",
|
"**/*.d.ts",
|
||||||
"**/*.cy.tsx",
|
"**/*.test.ts",
|
||||||
"**/*.cy.ts",
|
"**/*.test.tsx",
|
||||||
|
"**/*.browser.test.tsx",
|
||||||
"./coverage/**",
|
"./coverage/**",
|
||||||
"./cypress/**",
|
"./e2e/**",
|
||||||
"./dist/**",
|
"./dist/**",
|
||||||
"node_modules"
|
"node_modules"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -24,24 +24,127 @@ The project type checked and built with:
|
|||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
To run the tests make sure that xvfb is installed:
|
Install the Playwright browser (first time only):
|
||||||
|
|
||||||
```
|
```
|
||||||
apt install xvfb
|
npx playwright install --with-deps chromium
|
||||||
```
|
```
|
||||||
|
|
||||||
Run the development server in the background with Vite:
|
Then run the end-to-end tests (Playwright starts the dev server automatically):
|
||||||
|
|
||||||
```
|
```
|
||||||
nohup npm run start &
|
npm run test
|
||||||
```
|
```
|
||||||
|
|
||||||
Then start the Cypress tests with:
|
Run the unit tests with Vitest:
|
||||||
|
|
||||||
```
|
```
|
||||||
xvfb-run -a npm run test
|
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).
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
## main
|
## main
|
||||||
|
|
||||||
### ✨ Features and improvements
|
### ✨ Features and improvements
|
||||||
|
- Replace Cypress with Playwright for end-to-end tests and Vitest browser mode (Playwright provider) for component tests; drop the `@shellygo/cypress-test-utils` helper in favour of a `MaputnikDriver` page object
|
||||||
- _...Add new stuff here..._
|
- _...Add new stuff here..._
|
||||||
|
|
||||||
### 🐞 Bug fixes
|
### 🐞 Bug fixes
|
||||||
|
|||||||
@@ -79,26 +79,45 @@ npm run sort-styles
|
|||||||
```
|
```
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
For E2E testing we use [Cypress](https://www.cypress.io/)
|
|
||||||
|
|
||||||
[Cypress](https://www.cypress.io/) doesn't start a server so you'll need to start one manually by running `npm run start`.
|
### End-to-end tests
|
||||||
|
|
||||||
Now open a terminal and run the following using *chrome*:
|
For E2E testing we use [Playwright](https://playwright.dev/). The tests live in the [`e2e`](/e2e) directory and drive the app through the `MaputnikDriver` page object.
|
||||||
|
|
||||||
|
The first time you run the tests, install the browser:
|
||||||
|
|
||||||
|
```
|
||||||
|
npx playwright install chromium
|
||||||
|
```
|
||||||
|
|
||||||
|
Playwright automatically starts the dev server (`npm run start`) for you, so you can just run:
|
||||||
|
|
||||||
```
|
```
|
||||||
npm run test
|
npm run test
|
||||||
```
|
```
|
||||||
or *firefox*:
|
|
||||||
```
|
|
||||||
npm run test -- --browser firefox
|
|
||||||
```
|
|
||||||
|
|
||||||
See the following docs for more info: (Launching Browsers)[https://docs.cypress.io/guides/guides/launching-browsers]
|
Some useful options:
|
||||||
|
|
||||||
You can also see the tests as they run or select which suites to run by executing:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
npm run cy:open
|
# see the tests run in a headed browser
|
||||||
|
npm run test -- --headed
|
||||||
|
|
||||||
|
# run a single spec / filter by title
|
||||||
|
npm run test -- e2e/map.spec.ts
|
||||||
|
npm run test -- -g "zoom level"
|
||||||
|
|
||||||
|
# open the interactive UI mode
|
||||||
|
npx playwright test --ui
|
||||||
|
```
|
||||||
|
|
||||||
|
Running the E2E tests also produces a code-coverage report in `coverage/` (collected via istanbul instrumentation of the dev server).
|
||||||
|
|
||||||
|
### Unit & component tests
|
||||||
|
|
||||||
|
Unit tests and component tests run with [Vitest](https://vitest.dev/); component tests (`*.browser.test.tsx`) use Vitest's browser mode with the Playwright provider.
|
||||||
|
|
||||||
|
```
|
||||||
|
npm run test-unit
|
||||||
```
|
```
|
||||||
|
|
||||||
## Release process
|
## Release process
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
import { defineConfig } from "cypress";
|
|
||||||
import { createRequire } from "module";
|
|
||||||
const require = createRequire(import.meta.url);
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
env: {
|
|
||||||
codeCoverage: {
|
|
||||||
exclude: "cypress/**/*.*",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
e2e: {
|
|
||||||
setupNodeEvents(on, config) {
|
|
||||||
// implement node event listeners here
|
|
||||||
require("@cypress/code-coverage/task")(on, config);
|
|
||||||
on("before:browser:launch", (browser, launchOptions) => {
|
|
||||||
if (browser.family !== "chromium") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
launchOptions.args.push("--disable-gpu");
|
|
||||||
launchOptions.args.push("--enable-features=AllowSwiftShaderFallback,AllowSoftwareGLFallbackDueToCrashes");
|
|
||||||
launchOptions.args.push("--enable-unsafe-swiftshader");
|
|
||||||
return launchOptions;
|
|
||||||
});
|
|
||||||
return config;
|
|
||||||
},
|
|
||||||
baseUrl: "http://localhost:8888",
|
|
||||||
scrollBehavior: "center",
|
|
||||||
retries: {
|
|
||||||
runMode: 2,
|
|
||||||
openMode: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
component: {
|
|
||||||
devServer: {
|
|
||||||
framework: "react",
|
|
||||||
bundler: "vite",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { MaputnikDriver } from "./maputnik-driver";
|
|
||||||
|
|
||||||
describe("accessibility", () => {
|
|
||||||
const { beforeAndAfter, get, when, then } = new MaputnikDriver();
|
|
||||||
beforeAndAfter();
|
|
||||||
|
|
||||||
describe("skip links", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
when.setStyle("layer");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("skip link to layer list", () => {
|
|
||||||
const selector = "root:skip:layer-list";
|
|
||||||
then(get.elementByTestId(selector)).shouldExist();
|
|
||||||
when.tab();
|
|
||||||
then(get.elementByTestId(selector)).shouldBeFocused();
|
|
||||||
when.click(selector);
|
|
||||||
then(get.skipTargetLayerList()).shouldBeFocused();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("skip link to layer editor", () => {
|
|
||||||
const selector = "root:skip:layer-editor";
|
|
||||||
then(get.elementByTestId(selector)).shouldExist();
|
|
||||||
then(get.elementByTestId("skip-target-layer-editor")).shouldExist();
|
|
||||||
when.tab().tab();
|
|
||||||
then(get.elementByTestId(selector)).shouldBeFocused();
|
|
||||||
when.click(selector);
|
|
||||||
then(get.skipTargetLayerEditor()).shouldBeFocused();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("skip link to map view", () => {
|
|
||||||
const selector = "root:skip:map-view";
|
|
||||||
then(get.elementByTestId(selector)).shouldExist();
|
|
||||||
when.tab().tab().tab();
|
|
||||||
then(get.elementByTestId(selector)).shouldBeFocused();
|
|
||||||
when.click(selector);
|
|
||||||
then(get.canvas()).shouldBeFocused();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { MaputnikDriver } from "./maputnik-driver";
|
|
||||||
|
|
||||||
describe("code editor", () => {
|
|
||||||
const { beforeAndAfter, when, get, then } = new MaputnikDriver();
|
|
||||||
beforeAndAfter();
|
|
||||||
|
|
||||||
it("open code editor", () => {
|
|
||||||
when.click("nav:code-editor");
|
|
||||||
then(get.element(".maputnik-code-editor")).shouldExist();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("closes code editor", () => {
|
|
||||||
when.click("nav:code-editor");
|
|
||||||
then(get.element(".maputnik-code-editor")).shouldExist();
|
|
||||||
when.click("nav:code-editor");
|
|
||||||
then(get.element(".maputnik-code-editor")).shouldNotExist();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
import { MaputnikDriver } from "./maputnik-driver";
|
|
||||||
|
|
||||||
describe("history", () => {
|
|
||||||
const { beforeAndAfter, when, get, then } = new MaputnikDriver();
|
|
||||||
beforeAndAfter();
|
|
||||||
|
|
||||||
let undoKeyCombo: string;
|
|
||||||
let redoKeyCombo: string;
|
|
||||||
|
|
||||||
before(() => {
|
|
||||||
const isMac = get.isMac();
|
|
||||||
undoKeyCombo = isMac ? "{meta}z" : "{ctrl}z";
|
|
||||||
redoKeyCombo = isMac ? "{meta}{shift}z" : "{ctrl}y";
|
|
||||||
});
|
|
||||||
|
|
||||||
it("undo/redo", () => {
|
|
||||||
when.setStyle("geojson");
|
|
||||||
when.modal.open();
|
|
||||||
|
|
||||||
when.modal.fillLayers({
|
|
||||||
id: "step 1",
|
|
||||||
type: "background",
|
|
||||||
});
|
|
||||||
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
|
||||||
layers: [
|
|
||||||
{
|
|
||||||
id: "step 1",
|
|
||||||
type: "background",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
when.modal.open();
|
|
||||||
when.modal.fillLayers({
|
|
||||||
id: "step 2",
|
|
||||||
type: "background",
|
|
||||||
});
|
|
||||||
|
|
||||||
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
|
||||||
layers: [
|
|
||||||
{
|
|
||||||
id: "step 1",
|
|
||||||
type: "background",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "step 2",
|
|
||||||
type: "background",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
when.typeKeys(undoKeyCombo);
|
|
||||||
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
|
||||||
layers: [
|
|
||||||
{
|
|
||||||
id: "step 1",
|
|
||||||
type: "background",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
when.typeKeys(undoKeyCombo);
|
|
||||||
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ layers: [] });
|
|
||||||
|
|
||||||
when.typeKeys(redoKeyCombo);
|
|
||||||
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
|
||||||
layers: [
|
|
||||||
{
|
|
||||||
id: "step 1",
|
|
||||||
type: "background",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
when.typeKeys(redoKeyCombo);
|
|
||||||
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
|
||||||
layers: [
|
|
||||||
{
|
|
||||||
id: "step 1",
|
|
||||||
type: "background",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "step 2",
|
|
||||||
type: "background",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not redo after undo and value change", () => {
|
|
||||||
when.setStyle("geojson");
|
|
||||||
when.modal.open();
|
|
||||||
when.modal.fillLayers({
|
|
||||||
id: "step 1",
|
|
||||||
type: "background",
|
|
||||||
});
|
|
||||||
|
|
||||||
when.modal.open();
|
|
||||||
when.modal.fillLayers({
|
|
||||||
id: "step 2",
|
|
||||||
type: "background",
|
|
||||||
});
|
|
||||||
|
|
||||||
when.typeKeys(undoKeyCombo);
|
|
||||||
when.typeKeys(undoKeyCombo);
|
|
||||||
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ layers: [] });
|
|
||||||
|
|
||||||
when.modal.open();
|
|
||||||
when.modal.fillLayers({
|
|
||||||
id: "step 3",
|
|
||||||
type: "background",
|
|
||||||
});
|
|
||||||
|
|
||||||
when.typeKeys(redoKeyCombo);
|
|
||||||
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
|
||||||
layers: [
|
|
||||||
{
|
|
||||||
id: "step 3",
|
|
||||||
type: "background",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { MaputnikDriver } from "./maputnik-driver";
|
|
||||||
|
|
||||||
describe("i18n", () => {
|
|
||||||
const { beforeAndAfter, get, when, then } = new MaputnikDriver();
|
|
||||||
beforeAndAfter();
|
|
||||||
|
|
||||||
describe("language detector", () => {
|
|
||||||
it("English", () => {
|
|
||||||
const url = "?lng=en";
|
|
||||||
when.visit(url);
|
|
||||||
then(get.elementByTestId("maputnik-lang-select")).shouldHaveValue("en");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Japanese", () => {
|
|
||||||
const url = "?lng=ja";
|
|
||||||
when.visit(url);
|
|
||||||
then(get.elementByTestId("maputnik-lang-select")).shouldHaveValue("ja");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("language switcher", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
when.setStyle("layer");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("the language switcher switches to Japanese", () => {
|
|
||||||
const selector = "maputnik-lang-select";
|
|
||||||
then(get.elementByTestId(selector)).shouldExist();
|
|
||||||
when.select(selector, "ja");
|
|
||||||
then(get.elementByTestId(selector)).shouldHaveValue("ja");
|
|
||||||
|
|
||||||
then(get.elementByTestId("nav:settings")).shouldHaveText("スタイル設定");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { MaputnikDriver } from "./maputnik-driver";
|
|
||||||
|
|
||||||
describe("keyboard", () => {
|
|
||||||
const { beforeAndAfter, given, when, get, then } = new MaputnikDriver();
|
|
||||||
beforeAndAfter();
|
|
||||||
describe("shortcuts", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
given.setupMockBackedResponses();
|
|
||||||
when.setStyle("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ESC should unfocus", () => {
|
|
||||||
const targetSelector = "maputnik-select";
|
|
||||||
when.focus(targetSelector);
|
|
||||||
then(get.elementByTestId(targetSelector)).shouldBeFocused();
|
|
||||||
when.typeKeys("{esc}");
|
|
||||||
then(get.elementByTestId(targetSelector)).shouldNotBeFocused();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("'?' should show shortcuts modal", () => {
|
|
||||||
when.typeKeys("?");
|
|
||||||
then(get.elementByTestId("modal:shortcuts")).shouldBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("'o' should show open modal", () => {
|
|
||||||
when.typeKeys("o");
|
|
||||||
then(get.elementByTestId("modal:open")).shouldBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("'e' should show export modal", () => {
|
|
||||||
when.typeKeys("e");
|
|
||||||
then(get.elementByTestId("modal:export")).shouldBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("'d' should show sources modal", () => {
|
|
||||||
when.typeKeys("d");
|
|
||||||
then(get.elementByTestId("modal:sources")).shouldBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("'s' should show settings modal", () => {
|
|
||||||
when.typeKeys("s");
|
|
||||||
then(get.elementByTestId("modal:settings")).shouldBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("'i' should change map to inspect mode", () => {
|
|
||||||
when.typeKeys("i");
|
|
||||||
then(get.inputValue("maputnik-select")).shouldEqual("inspect");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("'m' should focus map", () => {
|
|
||||||
when.typeKeys("m");
|
|
||||||
then(get.canvas()).shouldBeFocused();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("'!' should show debug modal", () => {
|
|
||||||
when.typeKeys("!");
|
|
||||||
then(get.elementByTestId("modal:debug")).shouldBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,294 +0,0 @@
|
|||||||
import { MaputnikDriver } from "./maputnik-driver";
|
|
||||||
import { v1 as uuid } from "uuid";
|
|
||||||
|
|
||||||
describe("layer editor", () => {
|
|
||||||
const { beforeAndAfter, get, when, then } = new MaputnikDriver();
|
|
||||||
beforeAndAfter();
|
|
||||||
beforeEach(() => {
|
|
||||||
when.setStyle("both");
|
|
||||||
when.modal.open();
|
|
||||||
});
|
|
||||||
|
|
||||||
function createBackground() {
|
|
||||||
const id = uuid();
|
|
||||||
|
|
||||||
when.selectWithin("add-layer.layer-type", "background");
|
|
||||||
when.setValue("add-layer.layer-id.input", "background:" + id);
|
|
||||||
|
|
||||||
when.click("add-layer");
|
|
||||||
|
|
||||||
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
|
||||||
layers: [
|
|
||||||
{
|
|
||||||
id: "background:" + id,
|
|
||||||
type: "background",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
it("expand/collapse");
|
|
||||||
it("id", () => {
|
|
||||||
const bgId = createBackground();
|
|
||||||
|
|
||||||
when.click("layer-list-item:background:" + bgId);
|
|
||||||
|
|
||||||
const id = uuid();
|
|
||||||
when.setValue("layer-editor.layer-id.input", "foobar:" + id);
|
|
||||||
when.click("min-zoom");
|
|
||||||
|
|
||||||
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
|
||||||
layers: [
|
|
||||||
{
|
|
||||||
id: "foobar:" + id,
|
|
||||||
type: "background",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("source", () => {
|
|
||||||
it("should show error when the source is invalid", () => {
|
|
||||||
when.modal.fillLayers({
|
|
||||||
type: "circle",
|
|
||||||
layer: "invalid",
|
|
||||||
});
|
|
||||||
then(get.element(".maputnik-input-block--error .maputnik-input-block-label")).shouldHaveCss("color", "rgb(207, 74, 74)");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("min-zoom", () => {
|
|
||||||
let bgId: string;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
bgId = createBackground();
|
|
||||||
when.click("layer-list-item:background:" + bgId);
|
|
||||||
when.setValue("min-zoom.input-text", "1");
|
|
||||||
when.click("layer-editor.layer-id");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should update min-zoom in local storage", () => {
|
|
||||||
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
|
||||||
layers: [
|
|
||||||
{
|
|
||||||
id: "background:" + bgId,
|
|
||||||
type: "background",
|
|
||||||
minzoom: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("when clicking next layer should update style on local storage", () => {
|
|
||||||
when.type("min-zoom.input-text", "{backspace}");
|
|
||||||
when.click("max-zoom.input-text");
|
|
||||||
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
|
||||||
layers: [
|
|
||||||
{
|
|
||||||
id: "background:" + bgId,
|
|
||||||
type: "background",
|
|
||||||
minzoom: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("max-zoom", () => {
|
|
||||||
let bgId: string;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
bgId = createBackground();
|
|
||||||
when.click("layer-list-item:background:" + bgId);
|
|
||||||
when.setValue("max-zoom.input-text", "1");
|
|
||||||
when.click("layer-editor.layer-id");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should update style in local storage", () => {
|
|
||||||
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
|
||||||
layers: [
|
|
||||||
{
|
|
||||||
id: "background:" + bgId,
|
|
||||||
type: "background",
|
|
||||||
maxzoom: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("comments", () => {
|
|
||||||
let bgId: string;
|
|
||||||
const comment = "42";
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
bgId = createBackground();
|
|
||||||
when.click("layer-list-item:background:" + bgId);
|
|
||||||
when.setValue("layer-comment.input", comment);
|
|
||||||
when.click("layer-editor.layer-id");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should update style in local storage", () => {
|
|
||||||
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
|
||||||
layers: [
|
|
||||||
{
|
|
||||||
id: "background:" + bgId,
|
|
||||||
type: "background",
|
|
||||||
metadata: {
|
|
||||||
"maputnik:comment": comment,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("when unsetting", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
when.clear("layer-comment.input");
|
|
||||||
when.click("min-zoom.input-text");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should update style in local storage", () => {
|
|
||||||
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
|
||||||
layers: [
|
|
||||||
{
|
|
||||||
id: "background:" + bgId,
|
|
||||||
type: "background",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("color", () => {
|
|
||||||
let bgId: string;
|
|
||||||
beforeEach(() => {
|
|
||||||
bgId = createBackground();
|
|
||||||
when.click("layer-list-item:background:" + bgId);
|
|
||||||
when.click("spec-field:background-color");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should update style in local storage", () => {
|
|
||||||
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
|
||||||
layers: [
|
|
||||||
{
|
|
||||||
id: "background:" + bgId,
|
|
||||||
type: "background",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("opacity", () => {
|
|
||||||
let bgId: string;
|
|
||||||
beforeEach(() => {
|
|
||||||
bgId = createBackground();
|
|
||||||
when.click("layer-list-item:background:" + bgId);
|
|
||||||
when.type("spec-field-input:background-opacity", "0.");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should keep '.' in the input field", () => {
|
|
||||||
then(get.elementByTestId("spec-field-input:background-opacity")).shouldHaveValue("0.");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should revert to a valid value when focus out", () => {
|
|
||||||
when.click("layer-list-item:background:" + bgId);
|
|
||||||
then(get.elementByTestId("spec-field-input:background-opacity")).shouldHaveValue("0");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
describe("filter", () => {
|
|
||||||
it("expand/collapse");
|
|
||||||
it("compound filter");
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("layout", () => {
|
|
||||||
it("text-font", () => {
|
|
||||||
when.setStyle("font");
|
|
||||||
when.collapseGroupInLayerEditor();
|
|
||||||
when.collapseGroupInLayerEditor(1);
|
|
||||||
when.collapseGroupInLayerEditor(2);
|
|
||||||
when.doWithin("spec-field:text-font", () => {
|
|
||||||
get.element(".maputnik-autocomplete input").first().click();
|
|
||||||
});
|
|
||||||
then(get.element(".maputnik-autocomplete-menu-item")).shouldBeVisible();
|
|
||||||
then(get.element(".maputnik-autocomplete-menu-item")).shouldHaveLength(3);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("paint", () => {
|
|
||||||
it("expand/collapse");
|
|
||||||
it("color");
|
|
||||||
it("pattern");
|
|
||||||
it("opacity");
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("json-editor", () => {
|
|
||||||
it("add", () => {
|
|
||||||
const id = when.modal.fillLayers({
|
|
||||||
type: "circle",
|
|
||||||
layer: "example",
|
|
||||||
});
|
|
||||||
|
|
||||||
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
|
||||||
layers: [
|
|
||||||
{
|
|
||||||
id: id,
|
|
||||||
type: "circle",
|
|
||||||
source: "example",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const sourceText = get.elementByText('"source"');
|
|
||||||
|
|
||||||
sourceText.click();
|
|
||||||
sourceText.type("\"");
|
|
||||||
|
|
||||||
then(get.element(".cm-lint-marker-error")).shouldExist();
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
it("expand/collapse");
|
|
||||||
it("modify");
|
|
||||||
|
|
||||||
it("parse error", () => {
|
|
||||||
const bgId = createBackground();
|
|
||||||
|
|
||||||
when.click("layer-list-item:background:" + bgId);
|
|
||||||
when.collapseGroupInLayerEditor();
|
|
||||||
when.collapseGroupInLayerEditor(1);
|
|
||||||
then(get.element(".cm-lint-marker-error")).shouldNotExist();
|
|
||||||
|
|
||||||
when.appendTextInJsonEditor(
|
|
||||||
"\uE013\uE013\uE013\uE013\uE013\uE013\uE013\uE013\uE013\uE013\uE013\uE013 {"
|
|
||||||
);
|
|
||||||
then(get.element(".cm-lint-marker-error")).shouldExist();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("sticky header", () => {
|
|
||||||
it("should keep layer header visible when scrolling properties", () => {
|
|
||||||
// Setup: Create a layer with many properties (e.g., symbol layer)
|
|
||||||
when.modal.fillLayers({
|
|
||||||
type: "symbol",
|
|
||||||
layer: "example",
|
|
||||||
});
|
|
||||||
|
|
||||||
when.wait(500);
|
|
||||||
const header = get.elementByTestId("layer-editor.header");
|
|
||||||
then(header).shouldBeVisible();
|
|
||||||
|
|
||||||
get.element(".maputnik-scroll-container").scrollTo("bottom", { ensureScrollable: false });
|
|
||||||
when.wait(200);
|
|
||||||
|
|
||||||
then(header).shouldBeVisible();
|
|
||||||
then(get.elementByTestId("skip-target-layer-editor")).shouldBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,541 +0,0 @@
|
|||||||
import { MaputnikDriver } from "./maputnik-driver";
|
|
||||||
|
|
||||||
describe("layers list", () => {
|
|
||||||
const { beforeAndAfter, get, when, then } = new MaputnikDriver();
|
|
||||||
beforeAndAfter();
|
|
||||||
beforeEach(() => {
|
|
||||||
when.setStyle("both");
|
|
||||||
when.modal.open();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("ops", () => {
|
|
||||||
let id: string;
|
|
||||||
beforeEach(() => {
|
|
||||||
id = when.modal.fillLayers({
|
|
||||||
type: "background",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should update layers in local storage", () => {
|
|
||||||
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
|
||||||
layers: [
|
|
||||||
{
|
|
||||||
id: id,
|
|
||||||
type: "background",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("when clicking delete", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
when.click("layer-list-item:" + id + ":delete");
|
|
||||||
});
|
|
||||||
it("should empty layers in local storage", () => {
|
|
||||||
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
|
||||||
layers: [],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("when clicking duplicate", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
when.click("layer-list-item:" + id + ":copy");
|
|
||||||
});
|
|
||||||
it("should add copy layer in local storage", () => {
|
|
||||||
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
|
||||||
layers: [
|
|
||||||
{
|
|
||||||
id: id + "-copy",
|
|
||||||
type: "background",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: id,
|
|
||||||
type: "background",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("when clicking hide", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
when.click("layer-list-item:" + id + ":toggle-visibility");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should update visibility to none in local storage", () => {
|
|
||||||
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
|
||||||
layers: [
|
|
||||||
{
|
|
||||||
id: id,
|
|
||||||
type: "background",
|
|
||||||
layout: {
|
|
||||||
visibility: "none",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("when clicking show", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
when.click("layer-list-item:" + id + ":toggle-visibility");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should update visibility to visible in local storage", () => {
|
|
||||||
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
|
||||||
layers: [
|
|
||||||
{
|
|
||||||
id: id,
|
|
||||||
type: "background",
|
|
||||||
layout: {
|
|
||||||
visibility: "visible",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("when selecting a layer", () => {
|
|
||||||
let secondId: string;
|
|
||||||
beforeEach(() => {
|
|
||||||
when.modal.open();
|
|
||||||
secondId = when.modal.fillLayers({
|
|
||||||
id: "second-layer",
|
|
||||||
type: "background",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
it("should show the selected layer in the editor", () => {
|
|
||||||
when.realClick("layer-list-item:" + secondId);
|
|
||||||
then(get.elementByTestId("layer-editor.layer-id.input")).shouldHaveValue(secondId);
|
|
||||||
when.realClick("layer-list-item:" + id);
|
|
||||||
then(get.elementByTestId("layer-editor.layer-id.input")).shouldHaveValue(id);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("background", () => {
|
|
||||||
it("add", () => {
|
|
||||||
const id = when.modal.fillLayers({
|
|
||||||
type: "background",
|
|
||||||
});
|
|
||||||
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
|
||||||
layers: [
|
|
||||||
{
|
|
||||||
id: id,
|
|
||||||
type: "background",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("modify", () => {});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("fill", () => {
|
|
||||||
it("add", () => {
|
|
||||||
const id = when.modal.fillLayers({
|
|
||||||
type: "fill",
|
|
||||||
layer: "example",
|
|
||||||
});
|
|
||||||
|
|
||||||
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
|
||||||
layers: [
|
|
||||||
{
|
|
||||||
id: id,
|
|
||||||
type: "fill",
|
|
||||||
source: "example",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// TODO: Change source
|
|
||||||
it("change source");
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("line", () => {
|
|
||||||
it("add", () => {
|
|
||||||
const id = when.modal.fillLayers({
|
|
||||||
type: "line",
|
|
||||||
layer: "example",
|
|
||||||
});
|
|
||||||
|
|
||||||
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
|
||||||
layers: [
|
|
||||||
{
|
|
||||||
id: id,
|
|
||||||
type: "line",
|
|
||||||
source: "example",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("groups", () => {
|
|
||||||
when.modal.open();
|
|
||||||
const id1 = when.modal.fillLayers({
|
|
||||||
id: "aa",
|
|
||||||
type: "line",
|
|
||||||
layer: "example",
|
|
||||||
});
|
|
||||||
|
|
||||||
when.modal.open();
|
|
||||||
const id2 = when.modal.fillLayers({
|
|
||||||
id: "aa-2",
|
|
||||||
type: "line",
|
|
||||||
layer: "example",
|
|
||||||
});
|
|
||||||
|
|
||||||
when.modal.open();
|
|
||||||
const id3 = when.modal.fillLayers({
|
|
||||||
id: "b",
|
|
||||||
type: "line",
|
|
||||||
layer: "example",
|
|
||||||
});
|
|
||||||
|
|
||||||
then(get.elementByTestId("layer-list-item:" + id1)).shouldBeVisible();
|
|
||||||
then(get.elementByTestId("layer-list-item:" + id2)).shouldNotBeVisible();
|
|
||||||
then(get.elementByTestId("layer-list-item:" + id3)).shouldBeVisible();
|
|
||||||
when.click("layer-list-group:aa-0");
|
|
||||||
then(get.elementByTestId("layer-list-item:" + id1)).shouldBeVisible();
|
|
||||||
then(get.elementByTestId("layer-list-item:" + id2)).shouldBeVisible();
|
|
||||||
then(get.elementByTestId("layer-list-item:" + id3)).shouldBeVisible();
|
|
||||||
when.click("layer-list-item:" + id2);
|
|
||||||
when.click("skip-target-layer-editor");
|
|
||||||
when.click("menu-move-layer-down");
|
|
||||||
then(get.elementByTestId("layer-list-group:aa-0")).shouldNotExist();
|
|
||||||
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
|
||||||
layers: [
|
|
||||||
{
|
|
||||||
id: "aa",
|
|
||||||
type: "line",
|
|
||||||
source: "example",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "b",
|
|
||||||
type: "line",
|
|
||||||
source: "example",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "aa-2",
|
|
||||||
type: "line",
|
|
||||||
source: "example",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("symbol", () => {
|
|
||||||
it("add", () => {
|
|
||||||
const id = when.modal.fillLayers({
|
|
||||||
type: "symbol",
|
|
||||||
layer: "example",
|
|
||||||
});
|
|
||||||
|
|
||||||
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
|
||||||
layers: [
|
|
||||||
{
|
|
||||||
id: id,
|
|
||||||
type: "symbol",
|
|
||||||
source: "example",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show spec info when hovering and clicking single line property", () => {
|
|
||||||
when.modal.fillLayers({
|
|
||||||
type: "symbol",
|
|
||||||
layer: "example",
|
|
||||||
});
|
|
||||||
|
|
||||||
when.hover("spec-field-container:text-rotate");
|
|
||||||
then(get.elementByTestId("field-doc-button-Rotate")).shouldBeVisible();
|
|
||||||
when.click("field-doc-button-Rotate", 0);
|
|
||||||
then(get.elementByTestId("spec-field-doc")).shouldContainText("Rotates the ");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show spec info when hovering and clicking multi line property", () => {
|
|
||||||
when.modal.fillLayers({
|
|
||||||
type: "symbol",
|
|
||||||
layer: "example",
|
|
||||||
});
|
|
||||||
|
|
||||||
when.hover("spec-field-container:text-offset");
|
|
||||||
then(get.elementByTestId("field-doc-button-Offset")).shouldBeVisible();
|
|
||||||
when.click("field-doc-button-Offset", 0);
|
|
||||||
then(get.elementByTestId("spec-field-doc")).shouldContainText("Offset distance");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should hide spec info when clicking a second time", () => {
|
|
||||||
when.modal.fillLayers({
|
|
||||||
type: "symbol",
|
|
||||||
layer: "example",
|
|
||||||
});
|
|
||||||
|
|
||||||
when.hover("spec-field-container:text-rotate");
|
|
||||||
then(get.elementByTestId("field-doc-button-Rotate")).shouldBeVisible();
|
|
||||||
when.click("field-doc-button-Rotate", 0);
|
|
||||||
when.wait(200);
|
|
||||||
when.click("field-doc-button-Rotate", 0);
|
|
||||||
then(get.elementByTestId("spec-field-doc")).shouldNotBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("raster", () => {
|
|
||||||
it("add", () => {
|
|
||||||
const id = when.modal.fillLayers({
|
|
||||||
type: "raster",
|
|
||||||
layer: "raster",
|
|
||||||
});
|
|
||||||
|
|
||||||
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
|
||||||
layers: [
|
|
||||||
{
|
|
||||||
id: id,
|
|
||||||
type: "raster",
|
|
||||||
source: "raster",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("circle", () => {
|
|
||||||
it("add", () => {
|
|
||||||
const id = when.modal.fillLayers({
|
|
||||||
type: "circle",
|
|
||||||
layer: "example",
|
|
||||||
});
|
|
||||||
|
|
||||||
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
|
||||||
layers: [
|
|
||||||
{
|
|
||||||
id: id,
|
|
||||||
type: "circle",
|
|
||||||
source: "example",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("fill extrusion", () => {
|
|
||||||
it("add", () => {
|
|
||||||
const id = when.modal.fillLayers({
|
|
||||||
type: "fill-extrusion",
|
|
||||||
layer: "example",
|
|
||||||
});
|
|
||||||
|
|
||||||
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
|
||||||
layers: [
|
|
||||||
{
|
|
||||||
id: id,
|
|
||||||
type: "fill-extrusion",
|
|
||||||
source: "example",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("hillshade", () => {
|
|
||||||
it("add", () => {
|
|
||||||
const id = when.modal.fillLayers({
|
|
||||||
type: "hillshade",
|
|
||||||
layer: "example",
|
|
||||||
});
|
|
||||||
|
|
||||||
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
|
||||||
layers: [
|
|
||||||
{
|
|
||||||
id: id,
|
|
||||||
type: "hillshade",
|
|
||||||
source: "example",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("set hillshade illumination direction array", () => {
|
|
||||||
const id = when.modal.fillLayers({
|
|
||||||
type: "hillshade",
|
|
||||||
layer: "example",
|
|
||||||
});
|
|
||||||
when.collapseGroupInLayerEditor();
|
|
||||||
when.collapseGroupInLayerEditor(1);
|
|
||||||
when.setValueToPropertyArray("spec-field:hillshade-illumination-direction", "1");
|
|
||||||
when.addValueToPropertyArray("spec-field:hillshade-illumination-direction", "2");
|
|
||||||
when.addValueToPropertyArray("spec-field:hillshade-illumination-direction", "3");
|
|
||||||
when.addValueToPropertyArray("spec-field:hillshade-illumination-direction", "4");
|
|
||||||
|
|
||||||
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
|
||||||
layers: [
|
|
||||||
{
|
|
||||||
id: id,
|
|
||||||
type: "hillshade",
|
|
||||||
source: "example",
|
|
||||||
paint: {
|
|
||||||
"hillshade-illumination-direction": [ 1, 2, 3, 4 ]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("set hillshade highlight color array", () => {
|
|
||||||
const id = when.modal.fillLayers({
|
|
||||||
type: "hillshade",
|
|
||||||
layer: "example",
|
|
||||||
});
|
|
||||||
when.collapseGroupInLayerEditor();
|
|
||||||
when.setValueToPropertyArray("spec-field:hillshade-highlight-color", "blue");
|
|
||||||
when.addValueToPropertyArray("spec-field:hillshade-highlight-color", "#00ff00");
|
|
||||||
when.addValueToPropertyArray("spec-field:hillshade-highlight-color", "rgba(255, 255, 0, 1)");
|
|
||||||
|
|
||||||
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
|
||||||
layers: [
|
|
||||||
{
|
|
||||||
id: id,
|
|
||||||
type: "hillshade",
|
|
||||||
source: "example",
|
|
||||||
paint: {
|
|
||||||
"hillshade-highlight-color": [ "blue", "#00ff00", "rgba(255, 255, 0, 1)" ]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("color-relief", () => {
|
|
||||||
it("add", () => {
|
|
||||||
const id = when.modal.fillLayers({
|
|
||||||
type: "color-relief",
|
|
||||||
layer: "example",
|
|
||||||
});
|
|
||||||
|
|
||||||
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
|
||||||
layers: [
|
|
||||||
{
|
|
||||||
id: id,
|
|
||||||
type: "color-relief",
|
|
||||||
source: "example",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("adds elevation expression when clicking the elevation button", () => {
|
|
||||||
when.modal.fillLayers({
|
|
||||||
type: "color-relief",
|
|
||||||
layer: "example",
|
|
||||||
});
|
|
||||||
when.collapseGroupInLayerEditor();
|
|
||||||
when.click("make-elevation-function");
|
|
||||||
then(get.element("[data-wd-key='spec-field-container:color-relief-color'] .cm-line")).shouldBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("groups", () => {
|
|
||||||
it("simple", () => {
|
|
||||||
when.setStyle("geojson");
|
|
||||||
|
|
||||||
when.modal.open();
|
|
||||||
when.modal.fillLayers({
|
|
||||||
id: "foo",
|
|
||||||
type: "background",
|
|
||||||
});
|
|
||||||
|
|
||||||
when.modal.open();
|
|
||||||
when.modal.fillLayers({
|
|
||||||
id: "foo_bar",
|
|
||||||
type: "background",
|
|
||||||
});
|
|
||||||
|
|
||||||
when.modal.open();
|
|
||||||
when.modal.fillLayers({
|
|
||||||
id: "foo_bar_baz",
|
|
||||||
type: "background",
|
|
||||||
});
|
|
||||||
|
|
||||||
then(get.elementByTestId("layer-list-item:foo")).shouldBeVisible();
|
|
||||||
then(get.elementByTestId("layer-list-item:foo_bar")).shouldNotBeVisible();
|
|
||||||
then(
|
|
||||||
get.elementByTestId("layer-list-item:foo_bar_baz")
|
|
||||||
).shouldNotBeVisible();
|
|
||||||
when.click("layer-list-group:foo-0");
|
|
||||||
then(get.elementByTestId("layer-list-item:foo")).shouldBeVisible();
|
|
||||||
then(get.elementByTestId("layer-list-item:foo_bar")).shouldBeVisible();
|
|
||||||
then(
|
|
||||||
get.elementByTestId("layer-list-item:foo_bar_baz")
|
|
||||||
).shouldBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("drag and drop", () => {
|
|
||||||
it("move layer should update local storage", () => {
|
|
||||||
when.modal.open();
|
|
||||||
const firstId = when.modal.fillLayers({
|
|
||||||
id: "a",
|
|
||||||
type: "background",
|
|
||||||
});
|
|
||||||
when.modal.open();
|
|
||||||
const secondId = when.modal.fillLayers({
|
|
||||||
id: "b",
|
|
||||||
type: "background",
|
|
||||||
});
|
|
||||||
when.modal.open();
|
|
||||||
const thirdId = when.modal.fillLayers({
|
|
||||||
id: "c",
|
|
||||||
type: "background",
|
|
||||||
});
|
|
||||||
|
|
||||||
when.dragAndDropWithWait("layer-list-item:" + firstId, "layer-list-item:" + thirdId);
|
|
||||||
|
|
||||||
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
|
||||||
layers: [
|
|
||||||
{
|
|
||||||
id: secondId,
|
|
||||||
type: "background",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: thirdId,
|
|
||||||
type: "background",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: firstId,
|
|
||||||
type: "background",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("sticky header", () => {
|
|
||||||
it("should keep header visible when scrolling layer list", () => {
|
|
||||||
// Setup: Create multiple layers to enable scrolling
|
|
||||||
for (let i = 0; i < 20; i++) {
|
|
||||||
when.modal.open();
|
|
||||||
when.modal.fillLayers({
|
|
||||||
id: `layer-${i}`,
|
|
||||||
type: "background",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
when.wait(500);
|
|
||||||
const header = get.elementByTestId("layer-list.header");
|
|
||||||
then(header).shouldBeVisible();
|
|
||||||
|
|
||||||
// Scroll the layer list container (use ensureScrollable: false to avoid flakiness)
|
|
||||||
get.elementByTestId("layer-list").scrollTo("bottom", { ensureScrollable: false });
|
|
||||||
when.wait(200);
|
|
||||||
then(header).shouldBeVisible();
|
|
||||||
then(get.elementByTestId("layer-list:add-layer")).shouldBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import { MaputnikDriver } from "./maputnik-driver";
|
|
||||||
|
|
||||||
describe("map", () => {
|
|
||||||
const { beforeAndAfter, get, when, then } = new MaputnikDriver();
|
|
||||||
beforeAndAfter();
|
|
||||||
describe("zoom level", () => {
|
|
||||||
it("via url", () => {
|
|
||||||
const zoomLevel = 12.37;
|
|
||||||
when.setStyle("geojson", zoomLevel);
|
|
||||||
then(get.elementByTestId("maplibre:ctrl-zoom")).shouldBeVisible();
|
|
||||||
then(get.elementByTestId("maplibre:ctrl-zoom")).shouldContainText(
|
|
||||||
"Zoom: " + zoomLevel
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("via map controls", () => {
|
|
||||||
const zoomLevel = 12.37;
|
|
||||||
when.setStyle("geojson", zoomLevel);
|
|
||||||
then(get.elementByTestId("maplibre:ctrl-zoom")).shouldBeVisible();
|
|
||||||
when.clickZoomIn();
|
|
||||||
then(get.elementByTestId("maplibre:ctrl-zoom")).shouldContainText(
|
|
||||||
"Zoom: " + (zoomLevel + 1)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("via style file definition", () => {
|
|
||||||
when.setStyle("zoom_7_center_0_51");
|
|
||||||
then(get.elementByTestId("maplibre:ctrl-zoom")).shouldBeVisible();
|
|
||||||
then(get.elementByTestId("maplibre:ctrl-zoom")).shouldContainText(
|
|
||||||
"Zoom: " + (7)
|
|
||||||
);
|
|
||||||
then(get.locationHash().should("contain", "#7/51/0"));
|
|
||||||
|
|
||||||
// opening another stylefile does not update the map view again
|
|
||||||
// as discussed in https://github.com/maplibre/maputnik/issues/1546
|
|
||||||
when.openASecondStyleWithDifferentZoomAndCenter();
|
|
||||||
then(get.locationHash().should("contain", "#7/51/0"));
|
|
||||||
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("search", () => {
|
|
||||||
it("should exist", () => {
|
|
||||||
then(get.searchControl()).shouldBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("popup", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
when.setStyle("rectangles");
|
|
||||||
then(get.locationHash().should("exist"));
|
|
||||||
});
|
|
||||||
it("should open on feature click", () => {
|
|
||||||
when.clickCenter("maplibre:map");
|
|
||||||
then(get.elementByTestId("feature-layer-popup")).shouldBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should open a second feature after closing popup", () => {
|
|
||||||
when.clickCenter("maplibre:map");
|
|
||||||
then(get.elementByTestId("feature-layer-popup")).shouldBeVisible();
|
|
||||||
when.closePopup();
|
|
||||||
then(get.elementByTestId("feature-layer-popup")).shouldNotExist();
|
|
||||||
when.clickCenter("maplibre:map");
|
|
||||||
then(get.elementByTestId("feature-layer-popup")).shouldBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
/// <reference types="cypress-real-events" />
|
|
||||||
import { CypressHelper } from "@shellygo/cypress-test-utils";
|
|
||||||
import "cypress-real-events/support";
|
|
||||||
|
|
||||||
export default class MaputnikCypressHelper {
|
|
||||||
private helper = new CypressHelper({ defaultDataAttribute: "data-wd-key" });
|
|
||||||
|
|
||||||
public given = {
|
|
||||||
...this.helper.given,
|
|
||||||
};
|
|
||||||
|
|
||||||
public get = {
|
|
||||||
locationHash: (): Cypress.Chainable<string> => cy.location("hash"),
|
|
||||||
...this.helper.get,
|
|
||||||
};
|
|
||||||
|
|
||||||
public when = {
|
|
||||||
dragAndDropWithWait: (element: string, targetElement: string) => {
|
|
||||||
this.helper.get.elementByTestId(element).realMouseDown({ button: "left", position: "center" });
|
|
||||||
this.helper.get.elementByTestId(element).realMouseMove(0, 10, { position: "center" });
|
|
||||||
this.helper.get.elementByTestId(targetElement).realMouseMove(0, 0, { position: "center" });
|
|
||||||
this.helper.when.wait(1);
|
|
||||||
this.helper.get.elementByTestId(targetElement).realMouseUp();
|
|
||||||
},
|
|
||||||
clickCenter: (element: string) => {
|
|
||||||
this.helper.get.elementByTestId(element).realMouseDown({ button: "left", position: "center" });
|
|
||||||
this.helper.when.wait(200);
|
|
||||||
this.helper.get.elementByTestId(element).realMouseUp();
|
|
||||||
},
|
|
||||||
openFileByFixture: (fixture: string, buttonTestId: string, inputTestId: string) => {
|
|
||||||
cy.window().then((win) => {
|
|
||||||
const file = {
|
|
||||||
text: cy.stub().resolves(cy.fixture(fixture).then(JSON.stringify)),
|
|
||||||
};
|
|
||||||
const fileHandle = {
|
|
||||||
getFile: cy.stub().resolves(file),
|
|
||||||
};
|
|
||||||
if (!win.showOpenFilePicker) {
|
|
||||||
this.helper.get.elementByTestId(inputTestId).selectFile("cypress/fixtures/" + fixture, { force: true });
|
|
||||||
} else {
|
|
||||||
cy.stub(win, "showOpenFilePicker").resolves([fileHandle]);
|
|
||||||
this.helper.get.elementByTestId(buttonTestId).click();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
dropFileByFixture: (fixture: string, dropzoneTestId: string) => {
|
|
||||||
this.helper.get.elementByTestId(dropzoneTestId).selectFile("cypress/fixtures/" + fixture, {
|
|
||||||
action: "drag-drop",
|
|
||||||
force: true,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
...this.helper.when,
|
|
||||||
};
|
|
||||||
|
|
||||||
public beforeAndAfter = this.helper.beforeAndAfter;
|
|
||||||
}
|
|
||||||
@@ -1,272 +0,0 @@
|
|||||||
/// <reference types="cypress-plugin-tab" />
|
|
||||||
|
|
||||||
import { CypressHelper } from "@shellygo/cypress-test-utils";
|
|
||||||
import { Assertable, then } from "@shellygo/cypress-test-utils/assertable";
|
|
||||||
import MaputnikCypressHelper from "./maputnik-cypress-helper";
|
|
||||||
import ModalDriver from "./modal-driver";
|
|
||||||
const baseUrl = "http://localhost:8888/";
|
|
||||||
|
|
||||||
const styleFromWindow = (win: Window) => {
|
|
||||||
const styleId = win.localStorage.getItem("maputnik:latest_style");
|
|
||||||
const styleItemKey = `maputnik:style:${styleId}`;
|
|
||||||
const styleItem = win.localStorage.getItem(styleItemKey);
|
|
||||||
if (!styleItem) throw new Error("Could not get styleItem from localStorage");
|
|
||||||
const obj = JSON.parse(styleItem);
|
|
||||||
return obj;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class MaputnikAssertable<T> extends Assertable<T> {
|
|
||||||
shouldEqualToStoredStyle = () =>
|
|
||||||
then(
|
|
||||||
new CypressHelper().get.window().then((win: Window) => {
|
|
||||||
const style = styleFromWindow(win);
|
|
||||||
then(this.chainable).shouldDeepNestedInclude(style);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export class MaputnikDriver {
|
|
||||||
private helper = new MaputnikCypressHelper();
|
|
||||||
private modalDriver = new ModalDriver();
|
|
||||||
|
|
||||||
public beforeAndAfter = () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
this.given.setupMockBackedResponses();
|
|
||||||
this.when.setStyle("both");
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
public then = (chainable: Cypress.Chainable<any>) =>
|
|
||||||
new MaputnikAssertable(chainable);
|
|
||||||
|
|
||||||
public given = {
|
|
||||||
...this.helper.given,
|
|
||||||
setupMockBackedResponses: () => {
|
|
||||||
this.helper.given.interceptAndMockResponse({
|
|
||||||
method: "GET",
|
|
||||||
url: baseUrl + "example-style.json",
|
|
||||||
response: {
|
|
||||||
fixture: "example-style.json",
|
|
||||||
},
|
|
||||||
alias: "example-style.json",
|
|
||||||
});
|
|
||||||
this.helper.given.interceptAndMockResponse({
|
|
||||||
method: "GET",
|
|
||||||
url: baseUrl + "example-layer-style.json",
|
|
||||||
response: {
|
|
||||||
fixture: "example-layer-style.json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
this.helper.given.interceptAndMockResponse({
|
|
||||||
method: "GET",
|
|
||||||
url: baseUrl + "geojson-style.json",
|
|
||||||
response: {
|
|
||||||
fixture: "geojson-style.json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
this.helper.given.interceptAndMockResponse({
|
|
||||||
method: "GET",
|
|
||||||
url: baseUrl + "raster-style.json",
|
|
||||||
response: {
|
|
||||||
fixture: "raster-style.json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
this.helper.given.interceptAndMockResponse({
|
|
||||||
method: "GET",
|
|
||||||
url: baseUrl + "geojson-raster-style.json",
|
|
||||||
response: {
|
|
||||||
fixture: "geojson-raster-style.json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
this.helper.given.interceptAndMockResponse({
|
|
||||||
method: "GET",
|
|
||||||
url: baseUrl + "rectangles-style.json",
|
|
||||||
response: {
|
|
||||||
fixture: "rectangles-style.json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
this.helper.given.interceptAndMockResponse({
|
|
||||||
method: "GET",
|
|
||||||
url: baseUrl + "example-style-with-fonts.json",
|
|
||||||
response: {
|
|
||||||
fixture: "example-style-with-fonts.json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
this.helper.given.interceptAndMockResponse({
|
|
||||||
method: "GET",
|
|
||||||
url: baseUrl + "example-style-with-zoom-7-and-center-0-51.json",
|
|
||||||
response: {
|
|
||||||
fixture: "example-style-with-zoom-7-and-center-0-51.json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
this.helper.given.interceptAndMockResponse({
|
|
||||||
method: "GET",
|
|
||||||
url: baseUrl + "example-style-with-zoom-5-and-center-50-50.json",
|
|
||||||
response: {
|
|
||||||
fixture: "example-style-with-zoom-5-and-center-50-50.json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
this.helper.given.interceptAndMockResponse({
|
|
||||||
method: "GET",
|
|
||||||
url: "*example.local/*",
|
|
||||||
response: [],
|
|
||||||
});
|
|
||||||
this.helper.given.interceptAndMockResponse({
|
|
||||||
method: "GET",
|
|
||||||
url: "*example.com/*",
|
|
||||||
response: [],
|
|
||||||
});
|
|
||||||
this.helper.given.interceptAndMockResponse({
|
|
||||||
method: "GET",
|
|
||||||
url: "https://www.glyph-server.com/*",
|
|
||||||
response: ["Font 1", "Font 2", "Font 3"],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
public when = {
|
|
||||||
...this.helper.when,
|
|
||||||
modal: this.modalDriver.when,
|
|
||||||
doWithin: (selector: string, fn: () => void) => {
|
|
||||||
this.helper.when.doWithin(fn, selector);
|
|
||||||
},
|
|
||||||
tab: () => this.helper.get.element("body").tab(),
|
|
||||||
waitForExampleFileResponse: () => {
|
|
||||||
this.helper.when.waitForResponse("example-style.json");
|
|
||||||
},
|
|
||||||
openASecondStyleWithDifferentZoomAndCenter: () => {
|
|
||||||
cy.contains("button", "Open").click();
|
|
||||||
cy.get('[data-wd-key="modal:open.url.input"]')
|
|
||||||
.should("be.enabled")
|
|
||||||
.clear()
|
|
||||||
.type("http://localhost:8888/example-style-with-zoom-5-and-center-50-50.json{enter}");
|
|
||||||
},
|
|
||||||
chooseExampleFile: () => {
|
|
||||||
this.helper.given.fixture("example-style.json", "example-style.json");
|
|
||||||
this.helper.when.openFileByFixture("example-style.json", "modal:open.dropzone", "modal:open.file.input");
|
|
||||||
this.helper.when.wait(200);
|
|
||||||
},
|
|
||||||
dropExampleFile: () => {
|
|
||||||
this.helper.given.fixture("example-style.json", "example-style.json");
|
|
||||||
this.helper.when.dropFileByFixture("example-style.json", "modal:open.dropzone");
|
|
||||||
this.helper.when.wait(200);
|
|
||||||
},
|
|
||||||
setStyle: (
|
|
||||||
styleProperties: "geojson" | "raster" | "both" | "layer" | "rectangles" | "font" | "zoom_7_center_0_51" | "",
|
|
||||||
zoom?: number
|
|
||||||
) => {
|
|
||||||
const url = new URL(baseUrl);
|
|
||||||
switch (styleProperties) {
|
|
||||||
case "geojson":
|
|
||||||
url.searchParams.set("style", baseUrl + "geojson-style.json");
|
|
||||||
break;
|
|
||||||
case "raster":
|
|
||||||
url.searchParams.set("style", baseUrl + "raster-style.json");
|
|
||||||
break;
|
|
||||||
case "both":
|
|
||||||
url.searchParams.set("style", baseUrl + "geojson-raster-style.json");
|
|
||||||
break;
|
|
||||||
case "layer":
|
|
||||||
url.searchParams.set("style", baseUrl + "example-layer-style.json");
|
|
||||||
break;
|
|
||||||
case "rectangles":
|
|
||||||
url.searchParams.set("style", baseUrl + "rectangles-style.json");
|
|
||||||
break;
|
|
||||||
case "font":
|
|
||||||
url.searchParams.set("style", baseUrl + "example-style-with-fonts.json");
|
|
||||||
break;
|
|
||||||
case "zoom_7_center_0_51":
|
|
||||||
url.searchParams.set("style", baseUrl + "example-style-with-zoom-7-and-center-0-51.json");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (zoom) {
|
|
||||||
url.hash = `${zoom}/41.3805/2.1635`;
|
|
||||||
}
|
|
||||||
this.helper.when.visit(url.toString());
|
|
||||||
if (styleProperties) {
|
|
||||||
this.helper.when.acceptConfirm();
|
|
||||||
}
|
|
||||||
// when methods should not include assertions
|
|
||||||
const toolbarLink = this.helper.get.elementByTestId("toolbar:link");
|
|
||||||
toolbarLink.scrollIntoView();
|
|
||||||
toolbarLink.should("be.visible");
|
|
||||||
},
|
|
||||||
|
|
||||||
typeKeys: (keys: string) => this.helper.get.element("body").type(keys),
|
|
||||||
|
|
||||||
clickZoomIn: () => {
|
|
||||||
this.helper.get.element(".maplibregl-ctrl-zoom-in").click();
|
|
||||||
},
|
|
||||||
|
|
||||||
selectWithin: (selector: string, value: string) => {
|
|
||||||
this.when.doWithin(selector, () => {
|
|
||||||
this.helper.get.element("select").select(value);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
select: (selector: string, value: string) => {
|
|
||||||
this.helper.get.elementByTestId(selector).select(value);
|
|
||||||
},
|
|
||||||
|
|
||||||
focus: (selector: string) => {
|
|
||||||
this.helper.when.focus(selector);
|
|
||||||
},
|
|
||||||
|
|
||||||
setValue: (selector: string, text: string) => {
|
|
||||||
this.helper.get
|
|
||||||
.elementByTestId(selector)
|
|
||||||
.clear()
|
|
||||||
.type(text, { parseSpecialCharSequences: false });
|
|
||||||
},
|
|
||||||
|
|
||||||
setValueToPropertyArray: (selector: string, value: string) => {
|
|
||||||
this.when.doWithin(selector, () => {
|
|
||||||
this.helper.get.element(".maputnik-array-block-content input").last().type("{selectall}"+value, {force: true });
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
addValueToPropertyArray: (selector: string, value: string) => {
|
|
||||||
this.when.doWithin(selector, () => {
|
|
||||||
this.helper.get.element(".maputnik-array-add-value").click({ force: true });
|
|
||||||
this.helper.get.element(".maputnik-array-block-content input").last().type("{selectall}"+value, {force: true });
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
closePopup: () => {
|
|
||||||
this.helper.get.element(".maplibregl-popup-close-button").click();
|
|
||||||
},
|
|
||||||
|
|
||||||
collapseGroupInLayerEditor: (index = 0) => {
|
|
||||||
this.helper.get.element(".maputnik-layer-editor-group__button").eq(index).realClick();
|
|
||||||
},
|
|
||||||
|
|
||||||
appendTextInJsonEditor: (text: string) => {
|
|
||||||
this.helper.get.element(".cm-line").first().click().type(text, { parseSpecialCharSequences: false });
|
|
||||||
},
|
|
||||||
|
|
||||||
setTextInJsonEditor: (text: string) => {
|
|
||||||
this.helper.get.element(".cm-line").first().click().clear().type(text, { parseSpecialCharSequences: false });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
public get = {
|
|
||||||
...this.helper.get,
|
|
||||||
isMac: () => {
|
|
||||||
return Cypress.platform === "darwin";
|
|
||||||
},
|
|
||||||
|
|
||||||
styleFromLocalStorage: () =>
|
|
||||||
this.helper.get.window().then((win) => styleFromWindow(win)),
|
|
||||||
|
|
||||||
exampleFileUrl: () => {
|
|
||||||
return baseUrl + "example-style.json";
|
|
||||||
},
|
|
||||||
skipTargetLayerList: () =>
|
|
||||||
this.helper.get.elementByTestId("skip-target-layer-list"),
|
|
||||||
skipTargetLayerEditor: () =>
|
|
||||||
this.helper.get.elementByTestId("skip-target-layer-editor"),
|
|
||||||
canvas: () => this.helper.get.element("canvas"),
|
|
||||||
searchControl: () => this.helper.get.element(".maplibregl-ctrl-geocoder")
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { v1 as uuid } from "uuid";
|
|
||||||
import MaputnikCypressHelper from "./maputnik-cypress-helper";
|
|
||||||
|
|
||||||
export default class ModalDriver {
|
|
||||||
private helper = new MaputnikCypressHelper();
|
|
||||||
|
|
||||||
public when = {
|
|
||||||
fillLayers: (opts: { type: string; layer?: string; id?: string }) => {
|
|
||||||
// Having logic in test code is an anti pattern.
|
|
||||||
// This should be split to multiple single responsibility functions
|
|
||||||
const type = opts.type;
|
|
||||||
const layer = opts.layer;
|
|
||||||
let id;
|
|
||||||
if (opts.id) {
|
|
||||||
id = opts.id;
|
|
||||||
} else {
|
|
||||||
id = `${type}:${uuid()}`;
|
|
||||||
}
|
|
||||||
this.helper.when.selectOption("add-layer.layer-type.select", type);
|
|
||||||
this.helper.when.type("add-layer.layer-id.input", id);
|
|
||||||
|
|
||||||
if (layer) {
|
|
||||||
this.helper.when.doWithin(() => {
|
|
||||||
this.helper.get.element("input").clear().type(layer!);
|
|
||||||
}, "add-layer.layer-source-block");
|
|
||||||
}
|
|
||||||
this.helper.when.click("add-layer");
|
|
||||||
|
|
||||||
return id;
|
|
||||||
},
|
|
||||||
|
|
||||||
open: () => {
|
|
||||||
this.helper.when.click("layer-list:add-layer");
|
|
||||||
},
|
|
||||||
|
|
||||||
close: (key: string) => {
|
|
||||||
this.helper.when.click(key + ".close-modal");
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,456 +0,0 @@
|
|||||||
import { MaputnikDriver } from "./maputnik-driver";
|
|
||||||
import tokens from "../../src/config/tokens.json" with {type: "json"};
|
|
||||||
|
|
||||||
describe("modals", () => {
|
|
||||||
const { beforeAndAfter, when, get, given, then } = new MaputnikDriver();
|
|
||||||
beforeAndAfter();
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
when.setStyle("");
|
|
||||||
});
|
|
||||||
describe("open", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
when.click("nav:open");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("close", () => {
|
|
||||||
when.modal.close("modal:open");
|
|
||||||
then(get.elementByTestId("modal:open")).shouldNotExist();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("upload", () => {
|
|
||||||
when.chooseExampleFile();
|
|
||||||
then(get.fixture("example-style.json")).shouldEqualToStoredStyle();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("upload via drag and drop", () => {
|
|
||||||
when.dropExampleFile();
|
|
||||||
then(get.fixture("example-style.json")).shouldEqualToStoredStyle();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("when click open url", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
const styleFileUrl = get.exampleFileUrl();
|
|
||||||
|
|
||||||
when.setValue("modal:open.url.input", styleFileUrl);
|
|
||||||
when.click("modal:open.url.button");
|
|
||||||
when.wait(200);
|
|
||||||
});
|
|
||||||
it("load from url", () => {
|
|
||||||
then(get.responseBody("example-style.json")).shouldEqualToStoredStyle();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("shortcuts", () => {
|
|
||||||
it("open/close", () => {
|
|
||||||
when.setStyle("");
|
|
||||||
when.typeKeys("?");
|
|
||||||
when.modal.close("modal:shortcuts");
|
|
||||||
then(get.elementByTestId("modal:shortcuts")).shouldNotExist();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("export", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
when.click("nav:export");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("close", () => {
|
|
||||||
when.modal.close("modal:export");
|
|
||||||
then(get.elementByTestId("modal:export")).shouldNotExist();
|
|
||||||
});
|
|
||||||
|
|
||||||
// TODO: Work out how to download a file and check the contents
|
|
||||||
it("download");
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("sources", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
when.setStyle("layer");
|
|
||||||
when.click("nav:sources");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("active sources");
|
|
||||||
it("public source");
|
|
||||||
|
|
||||||
it("add new source", () => {
|
|
||||||
const sourceId = "n1z2v3r";
|
|
||||||
when.setValue("modal:sources.add.source_id", sourceId);
|
|
||||||
when.select("modal:sources.add.source_type", "tile_vector");
|
|
||||||
when.select("modal:sources.add.scheme_type", "tms");
|
|
||||||
when.click("modal:sources.add.add_source");
|
|
||||||
when.wait(200);
|
|
||||||
then(
|
|
||||||
get.styleFromLocalStorage().then((style) => style.sources[sourceId])
|
|
||||||
).shouldInclude({
|
|
||||||
scheme: "tms",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("add new pmtiles source", () => {
|
|
||||||
const sourceId = "pmtilestest";
|
|
||||||
when.setValue("modal:sources.add.source_id", sourceId);
|
|
||||||
when.select("modal:sources.add.source_type", "pmtiles_vector");
|
|
||||||
when.setValue("modal:sources.add.source_url", "https://data.source.coop/protomaps/openstreetmap/v4.pmtiles");
|
|
||||||
when.click("modal:sources.add.add_source");
|
|
||||||
when.click("modal:sources.add.add_source");
|
|
||||||
when.wait(200);
|
|
||||||
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
|
||||||
sources: {
|
|
||||||
pmtilestest: {
|
|
||||||
type: "vector",
|
|
||||||
url: "pmtiles://https://data.source.coop/protomaps/openstreetmap/v4.pmtiles",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("add new raster source", () => {
|
|
||||||
const sourceId = "rastertest";
|
|
||||||
when.setValue("modal:sources.add.source_id", sourceId);
|
|
||||||
when.select("modal:sources.add.source_type", "tile_raster");
|
|
||||||
when.select("modal:sources.add.scheme_type", "xyz");
|
|
||||||
when.setValue("modal:sources.add.tile_size", "128");
|
|
||||||
when.click("modal:sources.add.add_source");
|
|
||||||
when.wait(200);
|
|
||||||
then(
|
|
||||||
get.styleFromLocalStorage().then((style) => style.sources[sourceId])
|
|
||||||
).shouldInclude({
|
|
||||||
tileSize: 128,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("inspect", () => {
|
|
||||||
it("toggle", () => {
|
|
||||||
// There is no assertion in this test
|
|
||||||
when.setStyle("geojson");
|
|
||||||
when.select("maputnik-select", "inspect");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("style settings", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
when.click("nav:settings");
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("when click name filed spec information", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
when.click("field-doc-button-Name");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show the spec information", () => {
|
|
||||||
then(get.elementsText("spec-field-doc")).shouldInclude(
|
|
||||||
"name for the style"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("when set name and click owner", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
when.setValue("modal:settings.name", "foobar");
|
|
||||||
when.click("modal:settings.owner");
|
|
||||||
when.wait(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("show name specifications", () => {
|
|
||||||
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
|
||||||
name: "foobar",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("when set owner and click name", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
when.setValue("modal:settings.owner", "foobar");
|
|
||||||
when.click("modal:settings.name");
|
|
||||||
when.wait(200);
|
|
||||||
});
|
|
||||||
it("should update owner in local storage", () => {
|
|
||||||
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
|
||||||
owner: "foobar",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sprite url", () => {
|
|
||||||
when.setTextInJsonEditor("\"http://example.com\"");
|
|
||||||
when.click("modal:settings.name");
|
|
||||||
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
|
||||||
sprite: "http://example.com",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sprite object", () => {
|
|
||||||
when.setTextInJsonEditor(JSON.stringify([{ id: "1", url: "2" }]));
|
|
||||||
|
|
||||||
when.click("modal:settings.name");
|
|
||||||
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
|
||||||
sprite: [{ id: "1", url: "2" }],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("glyphs url", () => {
|
|
||||||
const glyphsUrl = "http://example.com/{fontstack}/{range}.pbf";
|
|
||||||
when.setValue("modal:settings.glyphs", glyphsUrl);
|
|
||||||
when.click("modal:settings.name");
|
|
||||||
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
|
||||||
glyphs: glyphsUrl,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("maptiler access token", () => {
|
|
||||||
const apiKey = "testing123";
|
|
||||||
when.setValue(
|
|
||||||
"modal:settings.maputnik:openmaptiles_access_token",
|
|
||||||
apiKey
|
|
||||||
);
|
|
||||||
when.click("modal:settings.name");
|
|
||||||
then(
|
|
||||||
get.styleFromLocalStorage().then((style) => style.metadata)
|
|
||||||
).shouldInclude({
|
|
||||||
"maputnik:openmaptiles_access_token": apiKey,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("thunderforest access token", () => {
|
|
||||||
const apiKey = "testing123";
|
|
||||||
when.setValue(
|
|
||||||
"modal:settings.maputnik:thunderforest_access_token",
|
|
||||||
apiKey
|
|
||||||
);
|
|
||||||
when.click("modal:settings.name");
|
|
||||||
then(
|
|
||||||
get.styleFromLocalStorage().then((style) => style.metadata)
|
|
||||||
).shouldInclude({ "maputnik:thunderforest_access_token": apiKey });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stadia access token", () => {
|
|
||||||
const apiKey = "testing123";
|
|
||||||
when.setValue(
|
|
||||||
"modal:settings.maputnik:stadia_access_token",
|
|
||||||
apiKey
|
|
||||||
);
|
|
||||||
when.click("modal:settings.name");
|
|
||||||
then(
|
|
||||||
get.styleFromLocalStorage().then((style) => style.metadata)
|
|
||||||
).shouldInclude({ "maputnik:stadia_access_token": apiKey });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("locationiq access token", () => {
|
|
||||||
const apiKey = "testing123";
|
|
||||||
when.setValue(
|
|
||||||
"modal:settings.maputnik:locationiq_access_token",
|
|
||||||
apiKey
|
|
||||||
);
|
|
||||||
when.click("modal:settings.name");
|
|
||||||
then(
|
|
||||||
get.styleFromLocalStorage().then((style) => style.metadata)
|
|
||||||
).shouldInclude({ "maputnik:locationiq_access_token": apiKey });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("style projection mercator", () => {
|
|
||||||
when.select("modal:settings.projection", "mercator");
|
|
||||||
then(
|
|
||||||
get.styleFromLocalStorage().then((style) => style.projection)
|
|
||||||
).shouldInclude({ type: "mercator" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("style projection globe", () => {
|
|
||||||
when.select("modal:settings.projection", "globe");
|
|
||||||
then(
|
|
||||||
get.styleFromLocalStorage().then((style) => style.projection)
|
|
||||||
).shouldInclude({ type: "globe" });
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
it("style projection vertical-perspective", () => {
|
|
||||||
when.select("modal:settings.projection", "vertical-perspective");
|
|
||||||
then(
|
|
||||||
get.styleFromLocalStorage().then((style) => style.projection)
|
|
||||||
).shouldInclude({ type: "vertical-perspective" });
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
it("style renderer", () => {
|
|
||||||
cy.on("uncaught:exception", () => false); // this is due to the fact that this is an invalid style for openlayers
|
|
||||||
when.select("modal:settings.maputnik:renderer", "ol");
|
|
||||||
then(get.inputValue("modal:settings.maputnik:renderer")).shouldEqual(
|
|
||||||
"ol"
|
|
||||||
);
|
|
||||||
|
|
||||||
when.click("modal:settings.name");
|
|
||||||
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
|
||||||
metadata: { "maputnik:renderer": "ol" },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
it("include API key when change renderer", () => {
|
|
||||||
|
|
||||||
when.click("modal:settings.close-modal");
|
|
||||||
when.click("nav:open");
|
|
||||||
|
|
||||||
get.elementByAttribute("aria-label", "MapTiler Basic").should("exist").click();
|
|
||||||
when.wait(1000);
|
|
||||||
when.click("nav:settings");
|
|
||||||
|
|
||||||
when.select("modal:settings.maputnik:renderer", "mlgljs");
|
|
||||||
then(get.inputValue("modal:settings.maputnik:renderer")).shouldEqual(
|
|
||||||
"mlgljs"
|
|
||||||
);
|
|
||||||
|
|
||||||
when.select("modal:settings.maputnik:renderer", "ol");
|
|
||||||
then(get.inputValue("modal:settings.maputnik:renderer")).shouldEqual(
|
|
||||||
"ol"
|
|
||||||
);
|
|
||||||
|
|
||||||
given.intercept("https://api.maptiler.com/tiles/v3-openmaptiles/tiles.json?key=*", "tileRequest", "GET");
|
|
||||||
|
|
||||||
when.select("modal:settings.maputnik:renderer", "mlgljs");
|
|
||||||
then(get.inputValue("modal:settings.maputnik:renderer")).shouldEqual(
|
|
||||||
"mlgljs"
|
|
||||||
);
|
|
||||||
|
|
||||||
when.waitForResponse("tileRequest").its("request").its("url").should("include", `https://api.maptiler.com/tiles/v3-openmaptiles/tiles.json?key=${tokens.openmaptiles}`);
|
|
||||||
when.waitForResponse("tileRequest").its("request").its("url").should("include", `https://api.maptiler.com/tiles/v3-openmaptiles/tiles.json?key=${tokens.openmaptiles}`);
|
|
||||||
when.waitForResponse("tileRequest").its("request").its("url").should("include", `https://api.maptiler.com/tiles/v3-openmaptiles/tiles.json?key=${tokens.openmaptiles}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("add layer", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
when.setStyle("layer");
|
|
||||||
when.modal.open();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows duplicate id error", () => {
|
|
||||||
when.setValue("add-layer.layer-id.input", "background");
|
|
||||||
when.click("add-layer");
|
|
||||||
then(get.elementByTestId("modal:add-layer")).shouldExist();
|
|
||||||
then(get.element(".maputnik-modal-error")).shouldContainText(
|
|
||||||
"Layer ID already exists"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("sources", () => {
|
|
||||||
it("toggle");
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("global state", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
when.click("nav:global-state");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("add variable", () => {
|
|
||||||
when.wait(100);
|
|
||||||
when.click("global-state-add-variable");
|
|
||||||
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
|
||||||
state: { key1: { default: "value" } },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
it("add multiple variables", () => {
|
|
||||||
when.click("global-state-add-variable");
|
|
||||||
when.click("global-state-add-variable");
|
|
||||||
when.click("global-state-add-variable");
|
|
||||||
when.wait(100);
|
|
||||||
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
|
||||||
state: { key1: { default: "value" }, key2: { default: "value" }, key3: { default: "value" } },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("remove variable", () => {
|
|
||||||
when.click("global-state-add-variable");
|
|
||||||
when.click("global-state-add-variable");
|
|
||||||
when.click("global-state-add-variable");
|
|
||||||
when.click("global-state-remove-variable", 0);
|
|
||||||
when.wait(100);
|
|
||||||
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
|
||||||
state: { key2: { default: "value" }, key3: { default: "value" } },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("edit variable key", () => {
|
|
||||||
when.click("global-state-add-variable");
|
|
||||||
when.wait(100);
|
|
||||||
when.setValue("global-state-variable-key:0", "mykey");
|
|
||||||
when.typeKeys("{enter}");
|
|
||||||
when.wait(100);
|
|
||||||
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
|
||||||
state: { mykey: { default: "value" } },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("edit variable value", () => {
|
|
||||||
when.click("global-state-add-variable");
|
|
||||||
when.wait(100);
|
|
||||||
when.setValue("global-state-variable-value:0", "myvalue");
|
|
||||||
when.typeKeys("{enter}");
|
|
||||||
when.wait(100);
|
|
||||||
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
|
||||||
state: { key1: { default: "myvalue" } },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("error panel", () => {
|
|
||||||
it("not visible when no errors", () => {
|
|
||||||
then(get.element("maputnik-message-panel-error")).shouldNotExist();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("visible on style error", () => {
|
|
||||||
when.modal.open();
|
|
||||||
when.modal.fillLayers({
|
|
||||||
type: "circle",
|
|
||||||
layer: "invalid",
|
|
||||||
});
|
|
||||||
then(get.element(".maputnik-message-panel-error")).shouldBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Handle localStorage QuotaExceededError", () => {
|
|
||||||
it("handles quota exceeded error when opening style from URL", () => {
|
|
||||||
// Clear localStorage to start fresh
|
|
||||||
cy.clearLocalStorage();
|
|
||||||
|
|
||||||
// fill localStorage until we get a QuotaExceededError
|
|
||||||
cy.window().then(win => {
|
|
||||||
let chunkSize = 1000;
|
|
||||||
const chunk = new Array(chunkSize).join("x");
|
|
||||||
let index = 0;
|
|
||||||
|
|
||||||
// Keep adding until we hit the quota
|
|
||||||
while (true) {
|
|
||||||
try {
|
|
||||||
const key = `maputnik:fill-${index++}`;
|
|
||||||
win.localStorage.setItem(key, chunk);
|
|
||||||
} catch (e: any) {
|
|
||||||
// Verify it's a quota error
|
|
||||||
if (e.name === "QuotaExceededError") {
|
|
||||||
if (chunkSize <= 1) return;
|
|
||||||
else {
|
|
||||||
chunkSize /= 2;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw e; // Unexpected error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Open the style via URL input
|
|
||||||
when.click("nav:open");
|
|
||||||
when.setValue("modal:open.url.input", get.exampleFileUrl());
|
|
||||||
when.click("modal:open.url.button");
|
|
||||||
|
|
||||||
then(get.responseBody("example-style.json")).shouldEqualToStoredStyle();
|
|
||||||
then(get.styleFromLocalStorage()).shouldExist();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
/// <reference types="cypress" />
|
|
||||||
// ***********************************************
|
|
||||||
// This example commands.ts shows you how to
|
|
||||||
// create various custom commands and overwrite
|
|
||||||
// existing commands.
|
|
||||||
//
|
|
||||||
// For more comprehensive examples of custom
|
|
||||||
// commands please read more here:
|
|
||||||
// https://on.cypress.io/custom-commands
|
|
||||||
// ***********************************************
|
|
||||||
//
|
|
||||||
//
|
|
||||||
// -- This is a parent command --
|
|
||||||
// Cypress.Commands.add('login', (email, password) => { ... })
|
|
||||||
//
|
|
||||||
//
|
|
||||||
// -- This is a child command --
|
|
||||||
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
|
|
||||||
//
|
|
||||||
//
|
|
||||||
// -- This is a dual command --
|
|
||||||
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
|
|
||||||
//
|
|
||||||
//
|
|
||||||
// -- This will overwrite an existing command --
|
|
||||||
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
|
|
||||||
//
|
|
||||||
// declare global {
|
|
||||||
// namespace Cypress {
|
|
||||||
// interface Chainable {
|
|
||||||
// login(email: string, password: string): Chainable<void>
|
|
||||||
// drag(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
|
|
||||||
// dismiss(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
|
|
||||||
// visit(originalFn: CommandOriginalFn, url: string, options: Partial<VisitOptions>): Chainable<Element>
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
|
||||||
<title>Components App</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div data-cy-root></div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
// ***********************************************************
|
|
||||||
// This example support/component.ts is processed and
|
|
||||||
// loaded automatically before your test files.
|
|
||||||
//
|
|
||||||
// This is a great place to put global configuration and
|
|
||||||
// behavior that modifies Cypress.
|
|
||||||
//
|
|
||||||
// You can change the location of this file or turn off
|
|
||||||
// automatically serving support files with the
|
|
||||||
// 'supportFile' configuration option.
|
|
||||||
//
|
|
||||||
// You can read more here:
|
|
||||||
// https://on.cypress.io/configuration
|
|
||||||
// ***********************************************************
|
|
||||||
|
|
||||||
// Import commands.js using ES2015 syntax:
|
|
||||||
import "./commands";
|
|
||||||
|
|
||||||
import { mount } from "cypress/react";
|
|
||||||
|
|
||||||
// Augment the Cypress namespace to include type definitions for
|
|
||||||
// your custom command.
|
|
||||||
// Alternatively, can be defined in cypress/support/component.d.ts
|
|
||||||
// with a <reference path="./component" /> at the top of your spec.
|
|
||||||
declare global {
|
|
||||||
/* eslint-disable @typescript-eslint/no-namespace */
|
|
||||||
namespace Cypress {
|
|
||||||
interface Chainable {
|
|
||||||
mount: typeof mount
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Cypress.Commands.add("mount", mount);
|
|
||||||
|
|
||||||
// Example use:
|
|
||||||
// cy.mount(<MyComponent />)
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
// ***********************************************************
|
|
||||||
// This example support/e2e.ts is processed and
|
|
||||||
// loaded automatically before your test files.
|
|
||||||
//
|
|
||||||
// This is a great place to put global configuration and
|
|
||||||
// behavior that modifies Cypress.
|
|
||||||
//
|
|
||||||
// You can change the location of this file or turn off
|
|
||||||
// automatically serving support files with the
|
|
||||||
// 'supportFile' configuration option.
|
|
||||||
//
|
|
||||||
// You can read more here:
|
|
||||||
// https://on.cypress.io/configuration
|
|
||||||
// ***********************************************************
|
|
||||||
|
|
||||||
// Import commands.js using ES2015 syntax:
|
|
||||||
import "@cypress/code-coverage/support";
|
|
||||||
import "cypress-plugin-tab";
|
|
||||||
import "./commands";
|
|
||||||
|
|
||||||
// Alternatively you can use CommonJS syntax:
|
|
||||||
// require('./commands')
|
|
||||||
@@ -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,48 @@
|
|||||||
|
import { test, describe, beforeEach } from "./utils/fixtures";
|
||||||
|
import { MaputnikDriver } from "./maputnik-driver";
|
||||||
|
|
||||||
|
describe("accessibility", () => {
|
||||||
|
const { given, get, when, then } = new MaputnikDriver();
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await given.setupMockBackedResponses();
|
||||||
|
await when.setStyle("both");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("skip links", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await when.setStyle("layer");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skip link to layer list", async () => {
|
||||||
|
const selector = "root:skip:layer-list";
|
||||||
|
await then(get.elementByTestId(selector)).shouldExist();
|
||||||
|
await when.tab();
|
||||||
|
await then(get.elementByTestId(selector)).shouldBeFocused();
|
||||||
|
await when.click(selector);
|
||||||
|
await then(get.skipTargetLayerList()).shouldBeFocused();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skip link to layer editor", async () => {
|
||||||
|
const selector = "root:skip:layer-editor";
|
||||||
|
await then(get.elementByTestId(selector)).shouldExist();
|
||||||
|
await then(get.elementByTestId("skip-target-layer-editor")).shouldExist();
|
||||||
|
await when.tab();
|
||||||
|
await when.tab();
|
||||||
|
await then(get.elementByTestId(selector)).shouldBeFocused();
|
||||||
|
await when.click(selector);
|
||||||
|
await then(get.skipTargetLayerEditor()).shouldBeFocused();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skip link to map view", async () => {
|
||||||
|
const selector = "root:skip:map-view";
|
||||||
|
await then(get.elementByTestId(selector)).shouldExist();
|
||||||
|
await when.tab();
|
||||||
|
await when.tab();
|
||||||
|
await when.tab();
|
||||||
|
await then(get.elementByTestId(selector)).shouldBeFocused();
|
||||||
|
await when.click(selector);
|
||||||
|
await then(get.canvas()).shouldBeFocused();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { beforeEach, describe, test } from "./utils/fixtures";
|
||||||
|
import { MaputnikDriver } from "./maputnik-driver";
|
||||||
|
|
||||||
|
describe("code editor", () => {
|
||||||
|
const { given, get, when, then } = new MaputnikDriver();
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await given.setupMockBackedResponses();
|
||||||
|
await when.setStyle("both");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("open code editor", async () => {
|
||||||
|
await when.click("nav:code-editor");
|
||||||
|
await then(get.element(".maputnik-code-editor")).shouldExist();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("closes code editor", async () => {
|
||||||
|
await when.click("nav:code-editor");
|
||||||
|
await then(get.element(".maputnik-code-editor")).shouldExist();
|
||||||
|
await when.click("nav:code-editor");
|
||||||
|
await then(get.element(".maputnik-code-editor")).shouldNotExist();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { beforeEach, describe, test } from "./utils/fixtures";
|
||||||
|
import { MaputnikDriver } from "./maputnik-driver";
|
||||||
|
|
||||||
|
describe("history", () => {
|
||||||
|
const { given, get, when, then } = new MaputnikDriver();
|
||||||
|
|
||||||
|
const undoKeyCombo = process.platform === "darwin" ? "{meta}z" : "{ctrl}z";
|
||||||
|
const redoKeyCombo = process.platform === "darwin" ? "{meta}{shift}z" : "{ctrl}y";
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await given.setupMockBackedResponses();
|
||||||
|
await when.setStyle("both");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("undo/redo", async () => {
|
||||||
|
await when.setStyle("geojson");
|
||||||
|
await when.modal.open();
|
||||||
|
|
||||||
|
await when.modal.fillLayers({
|
||||||
|
id: "step 1",
|
||||||
|
type: "background",
|
||||||
|
});
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id: "step 1", type: "background" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
await when.modal.open();
|
||||||
|
await when.modal.fillLayers({
|
||||||
|
id: "step 2",
|
||||||
|
type: "background",
|
||||||
|
});
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [
|
||||||
|
{ id: "step 1", type: "background" },
|
||||||
|
{ id: "step 2", type: "background" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await when.typeKeys(undoKeyCombo);
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id: "step 1", type: "background" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
await when.typeKeys(undoKeyCombo);
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ layers: [] });
|
||||||
|
|
||||||
|
await when.typeKeys(redoKeyCombo);
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id: "step 1", type: "background" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
await when.typeKeys(redoKeyCombo);
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [
|
||||||
|
{ id: "step 1", type: "background" },
|
||||||
|
{ id: "step 2", type: "background" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should not redo after undo and value change", async () => {
|
||||||
|
await when.setStyle("geojson");
|
||||||
|
await when.modal.open();
|
||||||
|
await when.modal.fillLayers({
|
||||||
|
id: "step 1",
|
||||||
|
type: "background",
|
||||||
|
});
|
||||||
|
|
||||||
|
await when.modal.open();
|
||||||
|
await when.modal.fillLayers({
|
||||||
|
id: "step 2",
|
||||||
|
type: "background",
|
||||||
|
});
|
||||||
|
|
||||||
|
await when.typeKeys(undoKeyCombo);
|
||||||
|
await when.typeKeys(undoKeyCombo);
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ layers: [] });
|
||||||
|
|
||||||
|
await when.modal.open();
|
||||||
|
await when.modal.fillLayers({
|
||||||
|
id: "step 3",
|
||||||
|
type: "background",
|
||||||
|
});
|
||||||
|
|
||||||
|
await when.typeKeys(redoKeyCombo);
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id: "step 3", type: "background" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { beforeEach, describe, test } from "./utils/fixtures";
|
||||||
|
import { MaputnikDriver } from "./maputnik-driver";
|
||||||
|
|
||||||
|
describe("i18n", () => {
|
||||||
|
const { given, get, when, then } = new MaputnikDriver();
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await given.setupMockBackedResponses();
|
||||||
|
await when.setStyle("both");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("language detector", () => {
|
||||||
|
test("English", async () => {
|
||||||
|
await when.visit("?lng=en");
|
||||||
|
await then(get.elementByTestId("maputnik-lang-select")).shouldHaveValue("en");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Japanese", async () => {
|
||||||
|
await when.visit("?lng=ja");
|
||||||
|
await then(get.elementByTestId("maputnik-lang-select")).shouldHaveValue("ja");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("language switcher", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await when.setStyle("layer");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the language switcher switches to Japanese", async () => {
|
||||||
|
const selector = "maputnik-lang-select";
|
||||||
|
await then(get.elementByTestId(selector)).shouldExist();
|
||||||
|
await when.select(selector, "ja");
|
||||||
|
await then(get.elementByTestId(selector)).shouldHaveValue("ja");
|
||||||
|
|
||||||
|
await then(get.elementByTestId("nav:settings")).shouldHaveText("スタイル設定");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { beforeEach, describe, test } from "./utils/fixtures";
|
||||||
|
import { MaputnikDriver } from "./maputnik-driver";
|
||||||
|
|
||||||
|
describe("keyboard", () => {
|
||||||
|
const { given, get, when, then } = new MaputnikDriver();
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await given.setupMockBackedResponses();
|
||||||
|
await when.setStyle("both");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shortcuts", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await given.setupMockBackedResponses();
|
||||||
|
await when.setStyle("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ESC should unfocus", async () => {
|
||||||
|
const targetSelector = "maputnik-select";
|
||||||
|
await when.focus(targetSelector);
|
||||||
|
await then(get.elementByTestId(targetSelector)).shouldBeFocused();
|
||||||
|
await when.typeKeys("{esc}");
|
||||||
|
await then(get.elementByTestId(targetSelector)).shouldNotBeFocused();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("'?' should show shortcuts modal", async () => {
|
||||||
|
await when.typeKeys("?");
|
||||||
|
await then(get.elementByTestId("modal:shortcuts")).shouldBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("'o' should show open modal", async () => {
|
||||||
|
await when.typeKeys("o");
|
||||||
|
await then(get.elementByTestId("modal:open")).shouldBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("'e' should show export modal", async () => {
|
||||||
|
await when.typeKeys("e");
|
||||||
|
await then(get.elementByTestId("modal:export")).shouldBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("'d' should show sources modal", async () => {
|
||||||
|
await when.typeKeys("d");
|
||||||
|
await then(get.elementByTestId("modal:sources")).shouldBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("'s' should show settings modal", async () => {
|
||||||
|
await when.typeKeys("s");
|
||||||
|
await then(get.elementByTestId("modal:settings")).shouldBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("'i' should change map to inspect mode", async () => {
|
||||||
|
await when.typeKeys("i");
|
||||||
|
await then(get.inputValue("maputnik-select")).shouldEqual("inspect");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("'m' should focus map", async () => {
|
||||||
|
await when.typeKeys("m");
|
||||||
|
await then(get.canvas()).shouldBeFocused();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("'!' should show debug modal", async () => {
|
||||||
|
await when.typeKeys("!");
|
||||||
|
await then(get.elementByTestId("modal:debug")).shouldBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,604 @@
|
|||||||
|
import { v1 as uuid } from "uuid";
|
||||||
|
import { beforeEach, describe, test } from "./utils/fixtures";
|
||||||
|
import { MaputnikDriver } from "./maputnik-driver";
|
||||||
|
|
||||||
|
describe("layer editor", () => {
|
||||||
|
const { given, get, when, then } = new MaputnikDriver();
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await given.setupMockBackedResponses();
|
||||||
|
await when.setStyle("both");
|
||||||
|
await when.modal.open();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createBackground() {
|
||||||
|
const id = uuid();
|
||||||
|
|
||||||
|
await when.selectWithin("add-layer.layer-type", "background");
|
||||||
|
await when.setValue("add-layer.layer-id.input", "background:" + id);
|
||||||
|
|
||||||
|
await when.click("add-layer");
|
||||||
|
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id: "background:" + id, type: "background" }],
|
||||||
|
});
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("expand/collapse", async () => {
|
||||||
|
const bgId = await createBackground();
|
||||||
|
await when.click("layer-list-item:background:" + bgId);
|
||||||
|
|
||||||
|
await then(get.elementByTestId("layer-editor.layer-id.input")).shouldBeVisible();
|
||||||
|
|
||||||
|
await when.toggleGroupInLayerEditor("Layer");
|
||||||
|
await then(get.elementByTestId("layer-editor.layer-id.input")).shouldNotBeVisible();
|
||||||
|
|
||||||
|
await when.toggleGroupInLayerEditor("Layer");
|
||||||
|
await then(get.elementByTestId("layer-editor.layer-id.input")).shouldBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("id", async () => {
|
||||||
|
const bgId = await createBackground();
|
||||||
|
|
||||||
|
await when.click("layer-list-item:background:" + bgId);
|
||||||
|
|
||||||
|
const id = uuid();
|
||||||
|
await when.setValue("layer-editor.layer-id.input", "foobar:" + id);
|
||||||
|
await when.click("min-zoom");
|
||||||
|
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id: "foobar:" + id, type: "background" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("source", () => {
|
||||||
|
test("should show error when the source is invalid", async () => {
|
||||||
|
await when.modal.fillLayers({
|
||||||
|
type: "circle",
|
||||||
|
layer: "invalid",
|
||||||
|
});
|
||||||
|
await then(
|
||||||
|
get.element(".maputnik-input-block--error .maputnik-input-block-label")
|
||||||
|
).shouldHaveCss("color", "rgb(207, 74, 74)");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("min-zoom", () => {
|
||||||
|
let bgId: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
bgId = await createBackground();
|
||||||
|
await when.click("layer-list-item:background:" + bgId);
|
||||||
|
await when.setValue("min-zoom.input-text", "1");
|
||||||
|
await when.click("layer-editor.layer-id");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should update min-zoom in local storage", async () => {
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id: "background:" + bgId, type: "background", minzoom: 1 }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("when clicking next layer should update style on local storage", async () => {
|
||||||
|
await when.type("min-zoom.input-text", "{backspace}");
|
||||||
|
await when.click("max-zoom.input-text");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
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", () => {
|
||||||
|
let bgId: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
bgId = await createBackground();
|
||||||
|
await when.click("layer-list-item:background:" + bgId);
|
||||||
|
await when.setValue("max-zoom.input-text", "1");
|
||||||
|
await when.click("layer-editor.layer-id");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should update style in local storage", async () => {
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id: "background:" + bgId, type: "background", maxzoom: 1 }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("comments", () => {
|
||||||
|
let bgId: string;
|
||||||
|
const comment = "42";
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
bgId = await createBackground();
|
||||||
|
await when.click("layer-list-item:background:" + bgId);
|
||||||
|
await when.setValue("layer-comment.input", comment);
|
||||||
|
await when.click("layer-editor.layer-id");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should update style in local storage", async () => {
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
id: "background:" + bgId,
|
||||||
|
type: "background",
|
||||||
|
metadata: { "maputnik:comment": comment },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when unsetting", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await when.clear("layer-comment.input");
|
||||||
|
await when.click("min-zoom.input-text");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should update style in local storage", async () => {
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id: "background:" + bgId, type: "background" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("color", () => {
|
||||||
|
let bgId: string;
|
||||||
|
beforeEach(async () => {
|
||||||
|
bgId = await createBackground();
|
||||||
|
await when.click("layer-list-item:background:" + bgId);
|
||||||
|
await when.click("spec-field:background-color");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should update style in local storage", async () => {
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
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", () => {
|
||||||
|
let bgId: string;
|
||||||
|
beforeEach(async () => {
|
||||||
|
bgId = await createBackground();
|
||||||
|
await when.click("layer-list-item:background:" + bgId);
|
||||||
|
await when.type("spec-field-input:background-opacity", "0.");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should keep '.' in the input field", async () => {
|
||||||
|
await then(get.elementByTestId("spec-field-input:background-opacity")).shouldHaveValue("0.");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should revert to a valid value when focus out", async () => {
|
||||||
|
await when.click("layer-list-item:background:" + bgId);
|
||||||
|
await then(get.elementByTestId("spec-field-input:background-opacity")).shouldHaveValue("0");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("filter", () => {
|
||||||
|
let id: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
id = await when.modal.fillLayers({ type: "fill", layer: "example" });
|
||||||
|
await when.addFilter();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should add a filter item", async () => {
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, type: "fill", source: "example", filter: ["all", ["==", "name", ""]] }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should change the filter operator", async () => {
|
||||||
|
await when.selectFilterOperator("!=");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, filter: ["all", ["!=", "name", ""]] }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should extend the compound filter with a second item", async () => {
|
||||||
|
await when.addFilter();
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, filter: ["all", ["==", "name", ""], ["==", "name", ""]] }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should change the combining operator", async () => {
|
||||||
|
await when.selectFilterCombiningOperator("any");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, filter: ["any", ["==", "name", ""]] }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should delete a filter item", async () => {
|
||||||
|
await when.addFilter();
|
||||||
|
await when.deleteFilterItem();
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, filter: ["all", ["==", "name", ""]] }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when converted to an expression", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await when.convertFilterToExpression();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should migrate the filter to an expression", async () => {
|
||||||
|
// A single-item "all" collapses to the bare comparison when migrated.
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, filter: ["==", ["get", "name"], ""] }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should restore the default filter when the expression is deleted", async () => {
|
||||||
|
await when.deleteFilterExpression();
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, filter: ["all"] }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("functions", () => {
|
||||||
|
let id: string;
|
||||||
|
|
||||||
|
describe("zoom function", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
id = await when.modal.fillLayers({ type: "circle", layer: "example" });
|
||||||
|
await when.makeZoomFunction("circle-radius");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should convert the property to a zoom function", async () => {
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [
|
||||||
|
{ id, type: "circle", source: "example", paint: { "circle-radius": { stops: [[6, 5], [10, 5]] } } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should add a stop", async () => {
|
||||||
|
await when.addFunctionStop("circle-radius");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, paint: { "circle-radius": { stops: [[6, 5], [10, 5], [11, 5]] } } }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should delete the first stop", async () => {
|
||||||
|
// A function needs more than two stops, otherwise deleting one collapses
|
||||||
|
// it back into a plain value.
|
||||||
|
await when.addFunctionStop("circle-radius");
|
||||||
|
await when.deleteFunctionStop("circle-radius");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, paint: { "circle-radius": { stops: [[10, 5], [11, 5]] } } }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should set the base", async () => {
|
||||||
|
await when.setFunctionBase("circle-radius", "2");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, paint: { "circle-radius": { base: 2, stops: [[6, 5], [10, 5]] } } }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should edit the zoom of a stop", async () => {
|
||||||
|
await when.setFunctionStopValue("circle-radius", "Zoom", 0, "3");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, paint: { "circle-radius": { stops: [[3, 5], [10, 5]] } } }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should edit the output value of a stop", async () => {
|
||||||
|
await when.setFunctionStopValue("circle-radius", "Output value", 0, "9");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, paint: { "circle-radius": { stops: [[6, 9], [10, 5]] } } }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should convert to an expression", async () => {
|
||||||
|
await when.makeExpression("circle-radius");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, paint: { "circle-radius": ["interpolate", ["linear"], ["zoom"], 6, 5, 10, 5] } }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when converted to a data function", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Any non-interpolate scale turns the zoom function into a data one.
|
||||||
|
await when.selectFunctionType("circle-radius", "categorical");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should carry the stops over as zoom/value pairs", async () => {
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
paint: {
|
||||||
|
"circle-radius": {
|
||||||
|
property: "",
|
||||||
|
type: "exponential",
|
||||||
|
stops: [[{ zoom: 6, value: 0 }, 5], [{ zoom: 10, value: 0 }, 5]],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should convert back to a zoom function", async () => {
|
||||||
|
await when.selectFunctionType("circle-radius", "interpolate");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, paint: { "circle-radius": { stops: [[6, 5], [10, 5]] } } }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("data function", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
id = await when.modal.fillLayers({ type: "circle", layer: "example" });
|
||||||
|
await when.setValue("spec-field-input:circle-blur", "1");
|
||||||
|
await when.makeDataFunction("circle-blur");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should convert the property to a data function", async () => {
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
type: "circle",
|
||||||
|
source: "example",
|
||||||
|
paint: {
|
||||||
|
"circle-blur": {
|
||||||
|
property: "",
|
||||||
|
type: "exponential",
|
||||||
|
stops: [[{ zoom: 6, value: 0 }, 1], [{ zoom: 10, value: 0 }, 1]],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should add a stop", async () => {
|
||||||
|
await when.addFunctionStop("circle-blur");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
paint: {
|
||||||
|
"circle-blur": {
|
||||||
|
stops: [[{ zoom: 6, value: 0 }, 1], [{ zoom: 10, value: 0 }, 1], [{ zoom: 11, value: 0 }, 1]],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should delete the first stop", async () => {
|
||||||
|
await when.addFunctionStop("circle-blur");
|
||||||
|
await when.deleteFunctionStop("circle-blur");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
paint: {
|
||||||
|
"circle-blur": {
|
||||||
|
stops: [[{ zoom: 10, value: 0 }, 1], [{ zoom: 11, value: 0 }, 1]],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should set the property", async () => {
|
||||||
|
await when.setFunctionProperty("circle-blur", "myprop");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, paint: { "circle-blur": { property: "myprop" } } }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should set the default", async () => {
|
||||||
|
await when.setFunctionDefault("circle-blur", "0.5");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, paint: { "circle-blur": { default: 0.5 } } }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should edit the input value of a stop", async () => {
|
||||||
|
await when.setFunctionStopValue("circle-blur", "Input value", 0, "7");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
paint: {
|
||||||
|
"circle-blur": {
|
||||||
|
stops: [[{ zoom: 6, value: 7 }, 1], [{ zoom: 10, value: 0 }, 1]],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should change the function type", async () => {
|
||||||
|
await when.selectFunctionType("circle-blur", "categorical");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, paint: { "circle-blur": { type: "categorical" } } }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("expression", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
id = await when.modal.fillLayers({ type: "circle", layer: "example" });
|
||||||
|
await when.setValue("spec-field-input:circle-blur", "1");
|
||||||
|
await when.makeExpression("circle-blur");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should wrap the property value in a literal expression", async () => {
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, paint: { "circle-blur": ["literal", 1] } }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should restore the plain value when reverted", async () => {
|
||||||
|
await when.undoExpression("circle-blur");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, paint: { "circle-blur": 1 } }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should fall back to the spec default when deleted", async () => {
|
||||||
|
await when.deleteExpression("circle-blur");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, paint: { "circle-blur": 0 } }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("layout", () => {
|
||||||
|
test("text-font", async () => {
|
||||||
|
await when.setStyle("font");
|
||||||
|
await when.collapseGroupInLayerEditor();
|
||||||
|
await when.collapseGroupInLayerEditor(1);
|
||||||
|
await when.collapseGroupInLayerEditor(2);
|
||||||
|
await when.clickWithin("spec-field:text-font", ".maputnik-autocomplete input");
|
||||||
|
await then(get.element(".maputnik-autocomplete-menu-item")).shouldBeVisible();
|
||||||
|
await then(get.element(".maputnik-autocomplete-menu-item")).shouldHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("paint", () => {
|
||||||
|
let id: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
id = await when.modal.fillLayers({ type: "fill", layer: "example" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expand/collapse", async () => {
|
||||||
|
await then(get.elementByTestId("spec-field:fill-color")).shouldBeVisible();
|
||||||
|
|
||||||
|
await when.toggleGroupInLayerEditor("Paint properties");
|
||||||
|
await then(get.elementByTestId("spec-field:fill-color")).shouldNotBeVisible();
|
||||||
|
|
||||||
|
await when.toggleGroupInLayerEditor("Paint properties");
|
||||||
|
await then(get.elementByTestId("spec-field:fill-color")).shouldBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("color", async () => {
|
||||||
|
await when.setColorValue("fill-color", "#ff0000");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, type: "fill", source: "example", paint: { "fill-color": "#ff0000" } }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pattern", async () => {
|
||||||
|
await when.setStringValue("fill-pattern", "some-pattern");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, type: "fill", source: "example", paint: { "fill-pattern": "some-pattern" } }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("opacity", async () => {
|
||||||
|
await when.setValue("spec-field-input:fill-opacity", "0.4");
|
||||||
|
await when.click("layer-editor.layer-id");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, type: "fill", source: "example", paint: { "fill-opacity": 0.4 } }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("json-editor", () => {
|
||||||
|
test("add", async () => {
|
||||||
|
const id = await when.modal.fillLayers({
|
||||||
|
type: "circle",
|
||||||
|
layer: "example",
|
||||||
|
});
|
||||||
|
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, type: "circle", source: "example" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
await when.clickByText('"source"');
|
||||||
|
await when.typeKeys('"');
|
||||||
|
|
||||||
|
await then(get.element(".cm-lint-marker-error")).shouldExist();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expand/collapse", async () => {
|
||||||
|
const bgId = await createBackground();
|
||||||
|
await when.click("layer-list-item:background:" + bgId);
|
||||||
|
|
||||||
|
await then(get.element(".cm-content")).shouldBeVisible();
|
||||||
|
|
||||||
|
await when.toggleGroupInLayerEditor("JSON Editor");
|
||||||
|
await then(get.element(".cm-content")).shouldNotBeVisible();
|
||||||
|
|
||||||
|
await when.toggleGroupInLayerEditor("JSON Editor");
|
||||||
|
await then(get.element(".cm-content")).shouldBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("modify", async () => {
|
||||||
|
const bgId = await createBackground();
|
||||||
|
await when.click("layer-list-item:background:" + bgId);
|
||||||
|
|
||||||
|
await when.appendToJsonEditorLine('"background"', ',\n"minzoom": 5');
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id: "background:" + bgId, type: "background", minzoom: 5 }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parse error", async () => {
|
||||||
|
const bgId = await createBackground();
|
||||||
|
|
||||||
|
await when.click("layer-list-item:background:" + bgId);
|
||||||
|
await when.collapseGroupInLayerEditor();
|
||||||
|
await when.collapseGroupInLayerEditor(1);
|
||||||
|
await then(get.element(".cm-lint-marker-error")).shouldNotExist();
|
||||||
|
|
||||||
|
// Inject an invalid token (CodeMirror auto-closes brackets/quotes, so a
|
||||||
|
// bare word reliably breaks the JSON) and expect a lint error.
|
||||||
|
await when.appendTextInJsonEditor("zzz");
|
||||||
|
await then(get.element(".cm-lint-marker-error")).shouldExist();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sticky header", () => {
|
||||||
|
test("should keep layer header visible when scrolling properties", async () => {
|
||||||
|
// Setup: Create a layer with many properties (e.g. symbol layer)
|
||||||
|
await when.modal.fillLayers({
|
||||||
|
type: "symbol",
|
||||||
|
layer: "example",
|
||||||
|
});
|
||||||
|
|
||||||
|
await when.wait(500);
|
||||||
|
const header = get.elementByTestId("layer-editor.header");
|
||||||
|
await then(header).shouldBeVisible();
|
||||||
|
|
||||||
|
await when.scrollToBottom(get.element(".maputnik-scroll-container"));
|
||||||
|
await when.wait(200);
|
||||||
|
|
||||||
|
await then(header).shouldBeVisible();
|
||||||
|
await then(get.elementByTestId("skip-target-layer-editor")).shouldBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
import { beforeEach, describe, test } from "./utils/fixtures";
|
||||||
|
import { MaputnikDriver } from "./maputnik-driver";
|
||||||
|
|
||||||
|
describe("layers list", () => {
|
||||||
|
const { given, get, when, then } = new MaputnikDriver();
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await given.setupMockBackedResponses();
|
||||||
|
await when.setStyle("both");
|
||||||
|
await when.modal.open();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ops", () => {
|
||||||
|
let id: string;
|
||||||
|
beforeEach(async () => {
|
||||||
|
id = await when.modal.fillLayers({ type: "background" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should update layers in local storage", async () => {
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, type: "background" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when clicking delete", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await when.click("layer-list-item:" + id + ":delete");
|
||||||
|
});
|
||||||
|
test("should empty layers in local storage", async () => {
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when clicking duplicate", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await when.click("layer-list-item:" + id + ":copy");
|
||||||
|
});
|
||||||
|
test("should add copy layer in local storage", async () => {
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [
|
||||||
|
{ id: id + "-copy", type: "background" },
|
||||||
|
{ id, type: "background" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when clicking hide", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await when.click("layer-list-item:" + id + ":toggle-visibility");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should update visibility to none in local storage", async () => {
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, type: "background", layout: { visibility: "none" } }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when clicking show", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await when.click("layer-list-item:" + id + ":toggle-visibility");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should update visibility to visible in local storage", async () => {
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, type: "background", layout: { visibility: "visible" } }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when selecting a layer", () => {
|
||||||
|
let secondId: string;
|
||||||
|
beforeEach(async () => {
|
||||||
|
await when.modal.open();
|
||||||
|
secondId = await when.modal.fillLayers({
|
||||||
|
id: "second-layer",
|
||||||
|
type: "background",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
test("should show the selected layer in the editor", async () => {
|
||||||
|
await when.realClick("layer-list-item:" + secondId);
|
||||||
|
await then(get.elementByTestId("layer-editor.layer-id.input")).shouldHaveValue(secondId);
|
||||||
|
await when.realClick("layer-list-item:" + id);
|
||||||
|
await then(get.elementByTestId("layer-editor.layer-id.input")).shouldHaveValue(id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("background", () => {
|
||||||
|
test("add", async () => {
|
||||||
|
const id = await when.modal.fillLayers({ type: "background" });
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, type: "background" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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", () => {
|
||||||
|
test("add", async () => {
|
||||||
|
const id = await when.modal.fillLayers({ type: "fill", layer: "example" });
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, type: "fill", source: "example" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("change source", async () => {
|
||||||
|
const id = await when.modal.fillLayers({ type: "fill", layer: "example" });
|
||||||
|
await when.changeLayerSource("raster");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, type: "fill", source: "raster" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("line", () => {
|
||||||
|
test("add", async () => {
|
||||||
|
const id = await when.modal.fillLayers({ type: "line", layer: "example" });
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, type: "line", source: "example" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("groups", async () => {
|
||||||
|
const id1 = await when.modal.fillLayers({ id: "aa", type: "line", layer: "example" });
|
||||||
|
|
||||||
|
await when.modal.open();
|
||||||
|
const id2 = await when.modal.fillLayers({ id: "aa-2", type: "line", layer: "example" });
|
||||||
|
|
||||||
|
await when.modal.open();
|
||||||
|
const id3 = await when.modal.fillLayers({ id: "b", type: "line", layer: "example" });
|
||||||
|
|
||||||
|
await then(get.elementByTestId("layer-list-item:" + id1)).shouldBeVisible();
|
||||||
|
await then(get.elementByTestId("layer-list-item:" + id2)).shouldNotBeVisible();
|
||||||
|
await then(get.elementByTestId("layer-list-item:" + id3)).shouldBeVisible();
|
||||||
|
await when.click("layer-list-group:aa-0");
|
||||||
|
await then(get.elementByTestId("layer-list-item:" + id1)).shouldBeVisible();
|
||||||
|
await then(get.elementByTestId("layer-list-item:" + id2)).shouldBeVisible();
|
||||||
|
await then(get.elementByTestId("layer-list-item:" + id3)).shouldBeVisible();
|
||||||
|
await when.click("layer-list-item:" + id2);
|
||||||
|
await when.click("skip-target-layer-editor");
|
||||||
|
await when.click("menu-move-layer-down");
|
||||||
|
await then(get.elementByTestId("layer-list-group:aa-0")).shouldNotExist();
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [
|
||||||
|
{ id: "aa", type: "line", source: "example" },
|
||||||
|
{ id: "b", type: "line", source: "example" },
|
||||||
|
{ id: "aa-2", type: "line", source: "example" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("symbol", () => {
|
||||||
|
test("add", async () => {
|
||||||
|
const id = await when.modal.fillLayers({ type: "symbol", layer: "example" });
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, type: "symbol", source: "example" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should show spec info when hovering and clicking single line property", async () => {
|
||||||
|
await when.modal.fillLayers({ type: "symbol", layer: "example" });
|
||||||
|
|
||||||
|
await when.hover("spec-field-container:text-rotate");
|
||||||
|
await then(get.elementByTestId("field-doc-button-Rotate")).shouldBeVisible();
|
||||||
|
await when.click("field-doc-button-Rotate", 0);
|
||||||
|
await then(get.elementByTestId("spec-field-doc")).shouldContainText("Rotates the ");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should show spec info when hovering and clicking multi line property", async () => {
|
||||||
|
await when.modal.fillLayers({ type: "symbol", layer: "example" });
|
||||||
|
|
||||||
|
await when.hover("spec-field-container:text-offset");
|
||||||
|
await then(get.elementByTestId("field-doc-button-Offset")).shouldBeVisible();
|
||||||
|
await when.click("field-doc-button-Offset", 0);
|
||||||
|
await then(get.elementByTestId("spec-field-doc")).shouldContainText("Offset distance");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should hide spec info when clicking a second time", async () => {
|
||||||
|
await when.modal.fillLayers({ type: "symbol", layer: "example" });
|
||||||
|
|
||||||
|
await when.hover("spec-field-container:text-rotate");
|
||||||
|
await then(get.elementByTestId("field-doc-button-Rotate")).shouldBeVisible();
|
||||||
|
await when.click("field-doc-button-Rotate", 0);
|
||||||
|
await when.wait(200);
|
||||||
|
await when.click("field-doc-button-Rotate", 0);
|
||||||
|
await then(get.elementByTestId("spec-field-doc")).shouldNotBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("raster", () => {
|
||||||
|
test("add", async () => {
|
||||||
|
const id = await when.modal.fillLayers({ type: "raster", layer: "raster" });
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, type: "raster", source: "raster" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("circle", () => {
|
||||||
|
test("add", async () => {
|
||||||
|
const id = await when.modal.fillLayers({ type: "circle", layer: "example" });
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, type: "circle", source: "example" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fill extrusion", () => {
|
||||||
|
test("add", async () => {
|
||||||
|
const id = await when.modal.fillLayers({ type: "fill-extrusion", layer: "example" });
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, type: "fill-extrusion", source: "example" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hillshade", () => {
|
||||||
|
test("add", async () => {
|
||||||
|
const id = await when.modal.fillLayers({ type: "hillshade", layer: "example" });
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, type: "hillshade", source: "example" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("set hillshade illumination direction array", async () => {
|
||||||
|
const id = await when.modal.fillLayers({ type: "hillshade", layer: "example" });
|
||||||
|
await when.collapseGroupInLayerEditor();
|
||||||
|
await when.collapseGroupInLayerEditor(1);
|
||||||
|
await when.setValueToPropertyArray("spec-field:hillshade-illumination-direction", "1");
|
||||||
|
await when.addValueToPropertyArray("spec-field:hillshade-illumination-direction", "2");
|
||||||
|
await when.addValueToPropertyArray("spec-field:hillshade-illumination-direction", "3");
|
||||||
|
await when.addValueToPropertyArray("spec-field:hillshade-illumination-direction", "4");
|
||||||
|
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
type: "hillshade",
|
||||||
|
source: "example",
|
||||||
|
paint: { "hillshade-illumination-direction": [1, 2, 3, 4] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("set hillshade highlight color array", async () => {
|
||||||
|
const id = await when.modal.fillLayers({ type: "hillshade", layer: "example" });
|
||||||
|
await when.collapseGroupInLayerEditor();
|
||||||
|
await when.setValueToPropertyArray("spec-field:hillshade-highlight-color", "blue");
|
||||||
|
await when.addValueToPropertyArray("spec-field:hillshade-highlight-color", "#00ff00");
|
||||||
|
await when.addValueToPropertyArray("spec-field:hillshade-highlight-color", "rgba(255, 255, 0, 1)");
|
||||||
|
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
type: "hillshade",
|
||||||
|
source: "example",
|
||||||
|
paint: {
|
||||||
|
"hillshade-highlight-color": ["blue", "#00ff00", "rgba(255, 255, 0, 1)"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("color-relief", () => {
|
||||||
|
test("add", async () => {
|
||||||
|
const id = await when.modal.fillLayers({ type: "color-relief", layer: "example" });
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [{ id, type: "color-relief", source: "example" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("adds elevation expression when clicking the elevation button", async () => {
|
||||||
|
await when.modal.fillLayers({ type: "color-relief", layer: "example" });
|
||||||
|
await when.collapseGroupInLayerEditor();
|
||||||
|
await when.click("make-elevation-function");
|
||||||
|
await then(
|
||||||
|
get.element("[data-wd-key='spec-field-container:color-relief-color'] .cm-line")
|
||||||
|
).shouldBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("groups", () => {
|
||||||
|
test("simple", async () => {
|
||||||
|
await when.setStyle("geojson");
|
||||||
|
|
||||||
|
await when.modal.open();
|
||||||
|
await when.modal.fillLayers({ id: "foo", type: "background" });
|
||||||
|
|
||||||
|
await when.modal.open();
|
||||||
|
await when.modal.fillLayers({ id: "foo_bar", type: "background" });
|
||||||
|
|
||||||
|
await when.modal.open();
|
||||||
|
await when.modal.fillLayers({ id: "foo_bar_baz", type: "background" });
|
||||||
|
|
||||||
|
await then(get.elementByTestId("layer-list-item:foo")).shouldBeVisible();
|
||||||
|
await then(get.elementByTestId("layer-list-item:foo_bar")).shouldNotBeVisible();
|
||||||
|
await then(get.elementByTestId("layer-list-item:foo_bar_baz")).shouldNotBeVisible();
|
||||||
|
await when.click("layer-list-group:foo-0");
|
||||||
|
await then(get.elementByTestId("layer-list-item:foo")).shouldBeVisible();
|
||||||
|
await then(get.elementByTestId("layer-list-item:foo_bar")).shouldBeVisible();
|
||||||
|
await then(get.elementByTestId("layer-list-item:foo_bar_baz")).shouldBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("drag and drop", () => {
|
||||||
|
test("move layer should update local storage", async () => {
|
||||||
|
const firstId = await when.modal.fillLayers({ id: "a", type: "background" });
|
||||||
|
await when.modal.open();
|
||||||
|
const secondId = await when.modal.fillLayers({ id: "b", type: "background" });
|
||||||
|
await when.modal.open();
|
||||||
|
const thirdId = await when.modal.fillLayers({ id: "c", type: "background" });
|
||||||
|
|
||||||
|
await when.dragAndDropWithWait("layer-list-item:" + firstId, "layer-list-item:" + thirdId);
|
||||||
|
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [
|
||||||
|
{ id: secondId, type: "background" },
|
||||||
|
{ id: thirdId, type: "background" },
|
||||||
|
{ id: firstId, type: "background" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sticky header", () => {
|
||||||
|
test("should keep header visible when scrolling layer list", async () => {
|
||||||
|
// Setup: Create multiple layers to enable scrolling
|
||||||
|
// The modal is already open (beforeEach) for the first layer.
|
||||||
|
await when.modal.fillLayers({ id: "layer-0", type: "background" });
|
||||||
|
for (let i = 1; i < 20; i++) {
|
||||||
|
await when.modal.open();
|
||||||
|
await when.modal.fillLayers({ id: `layer-${i}`, type: "background" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await when.wait(500);
|
||||||
|
const header = get.elementByTestId("layer-list.header");
|
||||||
|
await then(header).shouldBeVisible();
|
||||||
|
|
||||||
|
// Scroll the layer list container
|
||||||
|
await when.scrollToBottom(get.elementByTestId("layer-list"));
|
||||||
|
await when.wait(200);
|
||||||
|
await then(header).shouldBeVisible();
|
||||||
|
await then(get.elementByTestId("layer-list:add-layer")).shouldBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { beforeEach, describe, test } from "./utils/fixtures";
|
||||||
|
import { MaputnikDriver } from "./maputnik-driver";
|
||||||
|
|
||||||
|
describe("map", () => {
|
||||||
|
const { given, get, when, then } = new MaputnikDriver();
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await given.setupMockBackedResponses();
|
||||||
|
await when.setStyle("both");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("zoom level", () => {
|
||||||
|
test("via url", async () => {
|
||||||
|
const zoomLevel = 12.37;
|
||||||
|
await when.setStyle("geojson", zoomLevel);
|
||||||
|
await then(get.elementByTestId("maplibre:ctrl-zoom")).shouldBeVisible();
|
||||||
|
await then(get.elementByTestId("maplibre:ctrl-zoom")).shouldContainText("Zoom: " + zoomLevel);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("via map controls", async () => {
|
||||||
|
const zoomLevel = 12.37;
|
||||||
|
await when.setStyle("geojson", zoomLevel);
|
||||||
|
await then(get.elementByTestId("maplibre:ctrl-zoom")).shouldBeVisible();
|
||||||
|
await when.clickZoomIn();
|
||||||
|
await then(get.elementByTestId("maplibre:ctrl-zoom")).shouldContainText("Zoom: " + (zoomLevel + 1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("via style file definition", async () => {
|
||||||
|
await when.setStyle("zoom_7_center_0_51");
|
||||||
|
await then(get.elementByTestId("maplibre:ctrl-zoom")).shouldBeVisible();
|
||||||
|
await then(get.elementByTestId("maplibre:ctrl-zoom")).shouldContainText("Zoom: " + 7);
|
||||||
|
await then(get.locationHash()).shouldInclude("#7/51/0");
|
||||||
|
|
||||||
|
// opening another stylefile does not update the map view again
|
||||||
|
// as discussed in https://github.com/maplibre/maputnik/issues/1546
|
||||||
|
await when.openASecondStyleWithDifferentZoomAndCenter();
|
||||||
|
await then(get.locationHash()).shouldInclude("#7/51/0");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("search", () => {
|
||||||
|
test("should exist", async () => {
|
||||||
|
await then(get.searchControl()).shouldBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("popup", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await when.setStyle("rectangles");
|
||||||
|
await then(get.locationHash()).shouldExist();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should open on feature click", async () => {
|
||||||
|
await when.clickCenter("maplibre:map");
|
||||||
|
await then(get.elementByTestId("feature-layer-popup")).shouldBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should open a second feature after closing popup", async () => {
|
||||||
|
await when.clickCenter("maplibre:map");
|
||||||
|
await then(get.elementByTestId("feature-layer-popup")).shouldBeVisible();
|
||||||
|
await when.closePopup();
|
||||||
|
await then(get.elementByTestId("feature-layer-popup")).shouldNotExist();
|
||||||
|
await when.clickCenter("maplibre:map");
|
||||||
|
await then(get.elementByTestId("feature-layer-popup")).shouldBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
import { PlaywrightHelper } from "./playwright-helper";
|
||||||
|
import { ModalDriver } from "./modal-driver";
|
||||||
|
|
||||||
|
const baseUrl = "http://localhost:8888/";
|
||||||
|
const isMac = process.platform === "darwin";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The maputnik-specific driver. It builds on the generic {@link PlaywrightHelper}
|
||||||
|
* — spreading its `given`/`when`/`get` primitives and adding domain concepts
|
||||||
|
* (loading a style, the add-layer modal, the JSON editor, …). All Playwright
|
||||||
|
* access goes through the helper; the driver never touches `page` directly.
|
||||||
|
*/
|
||||||
|
export class MaputnikDriver {
|
||||||
|
private readonly helper = new PlaywrightHelper();
|
||||||
|
private readonly modalDriver = new ModalDriver();
|
||||||
|
|
||||||
|
then = this.helper.then;
|
||||||
|
|
||||||
|
/** Reads the maputnik style currently persisted in localStorage. */
|
||||||
|
private async readStoredStyle(): Promise<any> {
|
||||||
|
const styleId = await this.helper.get.localStorageItem("maputnik:latest_style");
|
||||||
|
const styleItem = await this.helper.get.localStorageItem(`maputnik:style:${styleId}`);
|
||||||
|
if (!styleItem) throw new Error("Could not get styleItem from localStorage");
|
||||||
|
return JSON.parse(styleItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
public given = {
|
||||||
|
...this.helper.given,
|
||||||
|
|
||||||
|
setupMockBackedResponses: async () => {
|
||||||
|
const styleFixtures = [
|
||||||
|
"example-style.json",
|
||||||
|
"example-layer-style.json",
|
||||||
|
"geojson-style.json",
|
||||||
|
"raster-style.json",
|
||||||
|
"geojson-raster-style.json",
|
||||||
|
"rectangles-style.json",
|
||||||
|
"example-style-with-fonts.json",
|
||||||
|
"example-style-with-zoom-7-and-center-0-51.json",
|
||||||
|
"example-style-with-zoom-5-and-center-50-50.json",
|
||||||
|
"access-token-style.json",
|
||||||
|
];
|
||||||
|
for (const fixture of styleFixtures) {
|
||||||
|
await this.helper.given.interceptAndMockResponse({
|
||||||
|
method: "GET",
|
||||||
|
url: baseUrl + fixture,
|
||||||
|
response: { fixture },
|
||||||
|
alias: fixture === "example-style.json" ? "example-style.json" : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await this.helper.given.interceptAndMockResponse({ method: "GET", url: /example\.local\//, response: [] });
|
||||||
|
await this.helper.given.interceptAndMockResponse({ method: "GET", url: /example\.com\//, response: [] });
|
||||||
|
await this.helper.given.interceptAndMockResponse({
|
||||||
|
method: "GET",
|
||||||
|
url: "https://www.glyph-server.com/*",
|
||||||
|
response: ["Font 1", "Font 2", "Font 3"],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
public when = {
|
||||||
|
...this.helper.when,
|
||||||
|
|
||||||
|
modal: this.modalDriver.when,
|
||||||
|
|
||||||
|
setStyle: async (
|
||||||
|
styleProperties:
|
||||||
|
| "geojson"
|
||||||
|
| "raster"
|
||||||
|
| "both"
|
||||||
|
| "layer"
|
||||||
|
| "rectangles"
|
||||||
|
| "font"
|
||||||
|
| "zoom_7_center_0_51"
|
||||||
|
| "access_tokens"
|
||||||
|
| "",
|
||||||
|
zoom?: number
|
||||||
|
) => {
|
||||||
|
const styleFileByKey: Record<string, string> = {
|
||||||
|
geojson: "geojson-style.json",
|
||||||
|
raster: "raster-style.json",
|
||||||
|
both: "geojson-raster-style.json",
|
||||||
|
layer: "example-layer-style.json",
|
||||||
|
rectangles: "rectangles-style.json",
|
||||||
|
font: "example-style-with-fonts.json",
|
||||||
|
zoom_7_center_0_51: "example-style-with-zoom-7-and-center-0-51.json",
|
||||||
|
access_tokens: "access-token-style.json",
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = new URL(baseUrl);
|
||||||
|
if (styleProperties && styleFileByKey[styleProperties]) {
|
||||||
|
url.searchParams.set("style", baseUrl + styleFileByKey[styleProperties]);
|
||||||
|
}
|
||||||
|
if (zoom) {
|
||||||
|
url.hash = `${zoom}/41.3805/2.1635`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.helper.when.visit(url.toString());
|
||||||
|
|
||||||
|
const toolbarLink = this.helper.get.elementByTestId("toolbar:link");
|
||||||
|
await toolbarLink.scrollIntoViewIfNeeded();
|
||||||
|
await this.then(toolbarLink).shouldBeVisible();
|
||||||
|
},
|
||||||
|
|
||||||
|
openASecondStyleWithDifferentZoomAndCenter: async () => {
|
||||||
|
await this.helper.when.clickButtonByName("Open");
|
||||||
|
const input = this.helper.get.elementByTestId("modal:open.url.input");
|
||||||
|
await input.fill("http://localhost:8888/example-style-with-zoom-5-and-center-50-50.json");
|
||||||
|
await input.press("Enter");
|
||||||
|
},
|
||||||
|
|
||||||
|
chooseExampleFile: async () => {
|
||||||
|
await this.helper.when.openFileByFixture("example-style.json", "modal:open.dropzone", "modal:open.file.input");
|
||||||
|
await this.helper.when.wait(200);
|
||||||
|
},
|
||||||
|
|
||||||
|
dropExampleFile: async () => {
|
||||||
|
await this.helper.when.dropFileByFixture("example-style.json", "modal:open.dropzone");
|
||||||
|
await this.helper.when.wait(200);
|
||||||
|
},
|
||||||
|
|
||||||
|
clickZoomIn: async () => {
|
||||||
|
await this.helper.get.element(".maplibregl-ctrl-zoom-in").click();
|
||||||
|
},
|
||||||
|
|
||||||
|
closePopup: async () => {
|
||||||
|
await this.helper.get.element(".maplibregl-popup-close-button").click();
|
||||||
|
},
|
||||||
|
|
||||||
|
collapseGroupInLayerEditor: async (index = 0) => {
|
||||||
|
await this.helper.get.element(".maputnik-layer-editor-group__button").nth(index).click();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Expands/collapses a layer-editor group by its title, e.g. "Paint properties". */
|
||||||
|
toggleGroupInLayerEditor: async (title: string) => {
|
||||||
|
await this.helper.when.click("layer-editor-group:" + title);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks a source for the selected layer from the source autocomplete.
|
||||||
|
* The autocomplete is a controlled (downshift) input, so the value has to be
|
||||||
|
* filled rather than typed key by key, then chosen from the filtered menu.
|
||||||
|
*/
|
||||||
|
changeLayerSource: async (sourceId: string) => {
|
||||||
|
const input = this.helper.get.elementByTestId("layer-editor.layer-source").locator("input");
|
||||||
|
await input.fill(sourceId);
|
||||||
|
await this.helper.get.element(".maputnik-autocomplete-menu-item").first().click();
|
||||||
|
},
|
||||||
|
|
||||||
|
appendTextInJsonEditor: async (text: string) => {
|
||||||
|
await this.helper.get.element(".cm-line").first().click();
|
||||||
|
// Move to the very start of the document so the inserted text breaks the
|
||||||
|
// root JSON structure (CodeMirror auto-closes brackets otherwise).
|
||||||
|
await this.helper.when.typeKeys("{home}");
|
||||||
|
await this.helper.when.typeText(text);
|
||||||
|
},
|
||||||
|
|
||||||
|
setTextInJsonEditor: async (text: string) => {
|
||||||
|
await this.helper.get.element(".cm-line").first().click();
|
||||||
|
await this.helper.when.typeKeys("{selectall}");
|
||||||
|
await this.helper.when.typeText(text);
|
||||||
|
},
|
||||||
|
|
||||||
|
setValueToPropertyArray: async (selector: string, value: string) => {
|
||||||
|
const input = this.helper.get.elementByTestId(selector).locator(".maputnik-array-block-content input").last();
|
||||||
|
await input.focus();
|
||||||
|
await this.helper.when.typeKeys("{selectall}");
|
||||||
|
await this.helper.when.typeText(value);
|
||||||
|
},
|
||||||
|
|
||||||
|
addValueToPropertyArray: async (selector: string, value: string) => {
|
||||||
|
const block = this.helper.get.elementByTestId(selector);
|
||||||
|
await block.locator(".maputnik-array-add-value").click();
|
||||||
|
const input = block.locator(".maputnik-array-block-content input").last();
|
||||||
|
await input.focus();
|
||||||
|
await this.helper.when.typeKeys("{selectall}");
|
||||||
|
await this.helper.when.typeText(value);
|
||||||
|
},
|
||||||
|
|
||||||
|
makeZoomFunction: async (fieldName: string) => {
|
||||||
|
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
|
||||||
|
await container.scrollIntoViewIfNeeded();
|
||||||
|
await container.locator(".maputnik-make-zoom-function").last().click({ force: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
makeDataFunction: async (fieldName: string) => {
|
||||||
|
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
|
||||||
|
await container.scrollIntoViewIfNeeded();
|
||||||
|
await container.locator(".maputnik-make-data-function").click({ force: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
addFunctionStop: async (fieldName: string) => {
|
||||||
|
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
|
||||||
|
await container.locator(".maputnik-add-stop").first().click({ force: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteFunctionStop: async (fieldName: string) => {
|
||||||
|
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
|
||||||
|
await container.locator(".maputnik-delete-stop").first().click({ force: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Turns the property into a raw style expression. */
|
||||||
|
makeExpression: async (fieldName: string) => {
|
||||||
|
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
|
||||||
|
await container.scrollIntoViewIfNeeded();
|
||||||
|
// In the plain spec field the expression button shares the zoom-function
|
||||||
|
// class and comes first; inside a function editor it has its own test id.
|
||||||
|
const inFunctionEditor = container.locator("[data-wd-key='convert-to-expression']");
|
||||||
|
const button =
|
||||||
|
(await inFunctionEditor.count()) > 0
|
||||||
|
? inFunctionEditor
|
||||||
|
: container.locator(".maputnik-make-zoom-function").first();
|
||||||
|
await button.click({ force: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Reverts an expression back to a plain value. */
|
||||||
|
undoExpression: async (fieldName: string) => {
|
||||||
|
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
|
||||||
|
await container.locator("[data-wd-key='undo-expression']").click({ force: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Removes an expression, restoring the property's spec default. */
|
||||||
|
deleteExpression: async (fieldName: string) => {
|
||||||
|
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
|
||||||
|
await container.locator("[data-wd-key='delete-expression']").click({ force: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Picks the function scale (categorical/interval/exponential/identity/interpolate). */
|
||||||
|
selectFunctionType: async (fieldName: string, type: string) => {
|
||||||
|
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
|
||||||
|
await container.locator("[data-wd-key='function-type'] select").selectOption(type);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Sets the "Base" input of a zoom/data function. */
|
||||||
|
setFunctionBase: async (fieldName: string, value: string) => {
|
||||||
|
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
|
||||||
|
await container.locator("[data-wd-key='function-base'] input").fill(value);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the data property a data function keys off of. This is an InputString,
|
||||||
|
* which only commits its value on blur, so typing alone is not enough.
|
||||||
|
*/
|
||||||
|
setFunctionProperty: async (fieldName: string, value: string) => {
|
||||||
|
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
|
||||||
|
const input = container.locator("[data-wd-key='function-property'] input");
|
||||||
|
await input.fill(value);
|
||||||
|
await input.blur();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Sets the fallback value used when a feature has no matching stop. */
|
||||||
|
setFunctionDefault: async (fieldName: string, value: string) => {
|
||||||
|
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
|
||||||
|
await container.locator("[data-wd-key='function-default'] input").fill(value);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Edits one cell of a function's stop table ("Zoom", "Input value" or "Output value"). */
|
||||||
|
setFunctionStopValue: async (fieldName: string, column: string, index: number, value: string) => {
|
||||||
|
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
|
||||||
|
await container.locator(`[aria-label="${column}"]`).nth(index).fill(value);
|
||||||
|
},
|
||||||
|
|
||||||
|
addFilter: async () => {
|
||||||
|
const button = this.helper.get.elementByTestId("layer-filter-button");
|
||||||
|
await button.scrollIntoViewIfNeeded();
|
||||||
|
await button.click({ force: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
selectFilterOperator: async (value: string) => {
|
||||||
|
await this.helper.get.element(".maputnik-filter-editor-operator select").first().selectOption(value);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Chooses how the filter items combine: all / none / any. */
|
||||||
|
selectFilterCombiningOperator: async (value: string) => {
|
||||||
|
await this.helper.when.selectWithin("filter-combining-operator", value);
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteFilterItem: async (index = 0) => {
|
||||||
|
await this.helper.get
|
||||||
|
.element(".maputnik-filter-editor-block-action .maputnik-icon-button")
|
||||||
|
.nth(index)
|
||||||
|
.click();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Converts the simple filter editor into a raw expression editor. */
|
||||||
|
convertFilterToExpression: async () => {
|
||||||
|
await this.helper.when.click("filter-convert-to-expression");
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the filter expression, restoring the simple filter editor.
|
||||||
|
* The filter group precedes the paint group, so its button comes first.
|
||||||
|
*/
|
||||||
|
deleteFilterExpression: async () => {
|
||||||
|
await this.helper.get.element("[data-wd-key='delete-expression']").first().click();
|
||||||
|
},
|
||||||
|
|
||||||
|
setColorValue: async (fieldName: string, value: string) => {
|
||||||
|
const input = this.helper.get.elementByTestId("spec-field:" + fieldName).locator(".maputnik-color");
|
||||||
|
await input.fill(value);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Sets a plain string spec field (e.g. a pattern), which has no dedicated input test id. */
|
||||||
|
setStringValue: async (fieldName: string, value: string) => {
|
||||||
|
const input = this.helper.get.elementByTestId("spec-field:" + fieldName).locator("input.maputnik-string");
|
||||||
|
await input.fill(value);
|
||||||
|
await input.blur();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends text to the end of the JSON editor line holding `lineText`.
|
||||||
|
* CodeMirror types over its own auto-inserted closing quotes/brackets, so a
|
||||||
|
* well-formed fragment stays well-formed.
|
||||||
|
*/
|
||||||
|
appendToJsonEditorLine: async (lineText: string, text: string) => {
|
||||||
|
await this.helper.when.clickByText(lineText);
|
||||||
|
await this.helper.when.typeKeys("{end}");
|
||||||
|
await this.helper.when.typeText(text);
|
||||||
|
},
|
||||||
|
|
||||||
|
waitForExampleFileResponse: () => this.helper.when.waitForResponse("example-style.json"),
|
||||||
|
|
||||||
|
/** Fill localStorage until we get a QuotaExceededError. */
|
||||||
|
fillLocalStorage: () => this.helper.when.fillLocalStorageUntilQuota("maputnik:fill-"),
|
||||||
|
};
|
||||||
|
|
||||||
|
public get = {
|
||||||
|
...this.helper.get,
|
||||||
|
|
||||||
|
isMac: () => isMac,
|
||||||
|
|
||||||
|
canvas: () => this.helper.get.element("canvas"),
|
||||||
|
|
||||||
|
searchControl: () => this.helper.get.element(".maplibregl-ctrl-geocoder"),
|
||||||
|
|
||||||
|
skipTargetLayerList: () => this.helper.get.elementByTestId("skip-target-layer-list"),
|
||||||
|
|
||||||
|
skipTargetLayerEditor: () => this.helper.get.elementByTestId("skip-target-layer-editor"),
|
||||||
|
|
||||||
|
styleFromLocalStorage: () => this.helper.query(() => this.readStoredStyle()),
|
||||||
|
|
||||||
|
fixture: (name: string) => this.helper.readFixture(name),
|
||||||
|
|
||||||
|
responseBody: (alias: string) => {
|
||||||
|
// Our mocked style responses always return the matching fixture.
|
||||||
|
const name = alias.endsWith(".json") ? alias : `${alias}.json`;
|
||||||
|
return this.helper.readFixture(name);
|
||||||
|
},
|
||||||
|
|
||||||
|
exampleFileUrl: () => baseUrl + "example-style.json",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { v1 as uuid } from "uuid";
|
||||||
|
import { PlaywrightHelper } from "./playwright-helper";
|
||||||
|
|
||||||
|
export class ModalDriver {
|
||||||
|
private readonly helper = new PlaywrightHelper();
|
||||||
|
|
||||||
|
public when = {
|
||||||
|
fillLayers: async (opts: { type: string; layer?: string; id?: string }) => {
|
||||||
|
const { when, get, then } = this.helper;
|
||||||
|
const id = opts.id ?? `${opts.type}:${uuid()}`;
|
||||||
|
|
||||||
|
await when.select("add-layer.layer-type.select", opts.type);
|
||||||
|
await when.type("add-layer.layer-id.input", id);
|
||||||
|
|
||||||
|
if (opts.layer) {
|
||||||
|
const input = get.elementByTestId("add-layer.layer-source-block").locator("input");
|
||||||
|
await input.click();
|
||||||
|
await input.fill(opts.layer);
|
||||||
|
// The source input is a controlled downshift combobox; wait for React to
|
||||||
|
// settle on the typed value before submitting.
|
||||||
|
await then(input).shouldHaveValue(opts.layer);
|
||||||
|
// Close the autocomplete menu so it does not intercept the add button.
|
||||||
|
await get.elementByTestId("add-layer.layer-id.input").click();
|
||||||
|
}
|
||||||
|
await when.click("add-layer");
|
||||||
|
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
|
||||||
|
open: async () => {
|
||||||
|
await this.helper.when.click("layer-list:add-layer");
|
||||||
|
},
|
||||||
|
|
||||||
|
close: async (key: string) => {
|
||||||
|
await this.helper.when.click(key + ".close-modal");
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a source of the given type from the sources modal, keeping whatever
|
||||||
|
* defaults that type's editor prefills.
|
||||||
|
*/
|
||||||
|
addSource: async (sourceId: string, sourceType: string) => {
|
||||||
|
const { when } = this.helper;
|
||||||
|
await when.setValue("modal:sources.add.source_id", sourceId);
|
||||||
|
await when.select("modal:sources.add.source_type", sourceType);
|
||||||
|
await when.click("modal:sources.add.add_source");
|
||||||
|
await when.wait(200);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Adds one of the predefined public sources listed in the sources modal. */
|
||||||
|
addPublicSource: async (index = 0) => {
|
||||||
|
await this.helper.get.element(".maputnik-public-source-select").nth(index).click();
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteFirstActiveSource: async () => {
|
||||||
|
await this.helper.get.element(".maputnik-active-source-type-editor-header-delete").first().click();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Fills one number box of a coordinate pair in the image/video source editor. */
|
||||||
|
setCoordinateValue: async (index: number, value: string) => {
|
||||||
|
const input = this.helper.get
|
||||||
|
.elementByTestId("modal:sources")
|
||||||
|
.locator(".maputnik-array input")
|
||||||
|
.nth(index);
|
||||||
|
await input.fill(value);
|
||||||
|
await input.blur();
|
||||||
|
},
|
||||||
|
|
||||||
|
exportCreateHtml: async () => {
|
||||||
|
await this.helper.get.element(".maputnik-modal-export-buttons button").last().click();
|
||||||
|
},
|
||||||
|
|
||||||
|
exportSaveStyle: async () => {
|
||||||
|
await this.helper.stubSaveFilePicker();
|
||||||
|
await this.helper.get.element(".maputnik-modal-export-buttons button").first().click();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,567 @@
|
|||||||
|
import { test, expect, describe, beforeEach } from "./utils/fixtures";
|
||||||
|
import { MaputnikDriver } from "./maputnik-driver";
|
||||||
|
import tokens from "../src/config/tokens.json" with { type: "json" };
|
||||||
|
|
||||||
|
describe("modals", () => {
|
||||||
|
const { given, get, when, then } = new MaputnikDriver();
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await given.setupMockBackedResponses();
|
||||||
|
await when.setStyle("both");
|
||||||
|
await when.setStyle("");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("open", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await when.click("nav:open");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("close", async () => {
|
||||||
|
await when.modal.close("modal:open");
|
||||||
|
await then(get.elementByTestId("modal:open")).shouldNotExist();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("upload", async () => {
|
||||||
|
await when.chooseExampleFile();
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude(get.fixture("example-style.json"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("upload via drag and drop", async () => {
|
||||||
|
await when.dropExampleFile();
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude(get.fixture("example-style.json"));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when click open url", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
const styleFileUrl = get.exampleFileUrl();
|
||||||
|
|
||||||
|
await when.setValue("modal:open.url.input", styleFileUrl);
|
||||||
|
await when.click("modal:open.url.button");
|
||||||
|
await when.wait(200);
|
||||||
|
});
|
||||||
|
test("load from url", async () => {
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude(get.responseBody("example-style.json"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shortcuts", () => {
|
||||||
|
test("open/close", async () => {
|
||||||
|
await when.setStyle("");
|
||||||
|
await when.typeKeys("?");
|
||||||
|
await when.modal.close("modal:shortcuts");
|
||||||
|
await then(get.elementByTestId("modal:shortcuts")).shouldNotExist();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("export", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await when.click("nav:export");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("close", async () => {
|
||||||
|
await when.modal.close("modal:export");
|
||||||
|
await then(get.elementByTestId("modal:export")).shouldNotExist();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("download HTML and save the style", async () => {
|
||||||
|
// Generate the standalone HTML export (triggers a file download).
|
||||||
|
await when.modal.exportCreateHtml();
|
||||||
|
await then(get.elementByTestId("modal:export")).shouldExist();
|
||||||
|
|
||||||
|
// Saving the style closes the export modal.
|
||||||
|
await when.modal.exportSaveStyle();
|
||||||
|
await then(get.elementByTestId("modal:export")).shouldNotExist();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sources", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await when.setStyle("layer");
|
||||||
|
await when.click("nav:sources");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("active sources are listed and can be deleted", async () => {
|
||||||
|
await when.setStyle("both");
|
||||||
|
await when.click("nav:sources");
|
||||||
|
const before = Object.keys(get.fixture("geojson-raster-style.json").sources).length;
|
||||||
|
await when.modal.deleteFirstActiveSource();
|
||||||
|
await then(
|
||||||
|
get.styleFromLocalStorage().then((style) => Object.keys(style.sources).length)
|
||||||
|
).shouldEqual(before - 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("public source", async () => {
|
||||||
|
await when.modal.addPublicSource();
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
sources: {
|
||||||
|
openmaptiles: {
|
||||||
|
type: "vector",
|
||||||
|
url: `https://api.maptiler.com/tiles/v3-openmaptiles/tiles.json?key=${tokens.openmaptiles}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("add new source", async () => {
|
||||||
|
const sourceId = "n1z2v3r";
|
||||||
|
await when.setValue("modal:sources.add.source_id", sourceId);
|
||||||
|
await when.select("modal:sources.add.source_type", "tile_vector");
|
||||||
|
await when.select("modal:sources.add.scheme_type", "tms");
|
||||||
|
await when.click("modal:sources.add.add_source");
|
||||||
|
await when.wait(200);
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
sources: { [sourceId]: { scheme: "tms" } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("add new pmtiles source", async () => {
|
||||||
|
const sourceId = "pmtilestest";
|
||||||
|
await when.setValue("modal:sources.add.source_id", sourceId);
|
||||||
|
await when.select("modal:sources.add.source_type", "pmtiles_vector");
|
||||||
|
await when.setValue(
|
||||||
|
"modal:sources.add.source_url",
|
||||||
|
"https://data.source.coop/protomaps/openstreetmap/v4.pmtiles"
|
||||||
|
);
|
||||||
|
await when.click("modal:sources.add.add_source");
|
||||||
|
await when.click("modal:sources.add.add_source");
|
||||||
|
await when.wait(200);
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
sources: {
|
||||||
|
pmtilestest: {
|
||||||
|
type: "vector",
|
||||||
|
url: "pmtiles://https://data.source.coop/protomaps/openstreetmap/v4.pmtiles",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("add new raster source", async () => {
|
||||||
|
const sourceId = "rastertest";
|
||||||
|
await when.setValue("modal:sources.add.source_id", sourceId);
|
||||||
|
await when.select("modal:sources.add.source_type", "tile_raster");
|
||||||
|
await when.select("modal:sources.add.scheme_type", "xyz");
|
||||||
|
await when.setValue("modal:sources.add.tile_size", "128");
|
||||||
|
await when.click("modal:sources.add.add_source");
|
||||||
|
await when.wait(200);
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
sources: { [sourceId]: { tileSize: 128 } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("add new geojson url source", async () => {
|
||||||
|
await when.modal.addSource("geojsonurl", "geojson_url");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
sources: {
|
||||||
|
geojsonurl: { type: "geojson", data: "http://localhost:3000/geojson.json" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("add new geojson json source", async () => {
|
||||||
|
await when.modal.addSource("geojsonjson", "geojson_json");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
sources: {
|
||||||
|
geojsonjson: { type: "geojson", cluster: false, data: "" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("add new tilejson vector source", async () => {
|
||||||
|
await when.modal.addSource("tilejsonvector", "tilejson_vector");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
sources: {
|
||||||
|
tilejsonvector: { type: "vector", url: "http://localhost:3000/tilejson.json" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("add new tilejson raster source", async () => {
|
||||||
|
await when.modal.addSource("tilejsonraster", "tilejson_raster");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
sources: {
|
||||||
|
tilejsonraster: { type: "raster", url: "http://localhost:3000/tilejson.json" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("add new tilejson raster-dem source", async () => {
|
||||||
|
await when.modal.addSource("tilejsonrasterdem", "tilejson_raster-dem");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
sources: {
|
||||||
|
tilejsonrasterdem: { type: "raster-dem", url: "http://localhost:3000/tilejson.json" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("add new tile xyz raster-dem source", async () => {
|
||||||
|
await when.modal.addSource("tilexyzrasterdem", "tilexyz_raster-dem");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
sources: {
|
||||||
|
tilexyzrasterdem: {
|
||||||
|
type: "raster-dem",
|
||||||
|
tiles: ["http://localhost:3000/{x}/{y}/{z}.png"],
|
||||||
|
minzoom: 0,
|
||||||
|
maxzoom: 14,
|
||||||
|
tileSize: 512,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("add new image source", async () => {
|
||||||
|
await when.modal.addSource("imagesource", "image");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
sources: {
|
||||||
|
imagesource: {
|
||||||
|
type: "image",
|
||||||
|
url: "http://localhost:3000/image.png",
|
||||||
|
coordinates: [[0, 0], [0, 0], [0, 0], [0, 0]],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("add new video source", async () => {
|
||||||
|
await when.modal.addSource("videosource", "video");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
sources: {
|
||||||
|
videosource: {
|
||||||
|
type: "video",
|
||||||
|
urls: ["http://localhost:3000/movie.mp4"],
|
||||||
|
coordinates: [[0, 0], [0, 0], [0, 0], [0, 0]],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("edit the corner coordinates of an image source", async () => {
|
||||||
|
const sourceId = "imagecoords";
|
||||||
|
await when.setValue("modal:sources.add.source_id", sourceId);
|
||||||
|
await when.select("modal:sources.add.source_type", "image");
|
||||||
|
// The first corner is the first two number boxes of the coordinate arrays.
|
||||||
|
await when.modal.setCoordinateValue(0, "1");
|
||||||
|
await when.modal.setCoordinateValue(1, "2");
|
||||||
|
await when.click("modal:sources.add.add_source");
|
||||||
|
await when.wait(200);
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
sources: {
|
||||||
|
[sourceId]: { type: "image", coordinates: [[1, 2], [0, 0], [0, 0], [0, 0]] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("inspect", () => {
|
||||||
|
test("toggle", async () => {
|
||||||
|
// There is no assertion in this test
|
||||||
|
await when.setStyle("geojson");
|
||||||
|
await when.select("maputnik-select", "inspect");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("style settings", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await when.click("nav:settings");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when click name filed spec information", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await when.click("field-doc-button-Name");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should show the spec information", async () => {
|
||||||
|
await then(get.elementsText("spec-field-doc")).shouldInclude("name for the style");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when set name and click owner", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await when.setValue("modal:settings.name", "foobar");
|
||||||
|
await when.click("modal:settings.owner");
|
||||||
|
await when.wait(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("show name specifications", async () => {
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
name: "foobar",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when set owner and click name", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await when.setValue("modal:settings.owner", "foobar");
|
||||||
|
await when.click("modal:settings.name");
|
||||||
|
await when.wait(200);
|
||||||
|
});
|
||||||
|
test("should update owner in local storage", async () => {
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
owner: "foobar",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sprite url", async () => {
|
||||||
|
await when.setTextInJsonEditor('"http://example.com"');
|
||||||
|
await when.click("modal:settings.name");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
sprite: "http://example.com",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sprite object", async () => {
|
||||||
|
await when.setTextInJsonEditor(JSON.stringify([{ id: "1", url: "2" }]));
|
||||||
|
|
||||||
|
await when.click("modal:settings.name");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
sprite: [{ id: "1", url: "2" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("glyphs url", async () => {
|
||||||
|
const glyphsUrl = "http://example.com/{fontstack}/{range}.pbf";
|
||||||
|
await when.setValue("modal:settings.glyphs", glyphsUrl);
|
||||||
|
await when.click("modal:settings.name");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
glyphs: glyphsUrl,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("maptiler access token", async () => {
|
||||||
|
const apiKey = "testing123";
|
||||||
|
await when.setValue("modal:settings.maputnik:openmaptiles_access_token", apiKey);
|
||||||
|
await when.click("modal:settings.name");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
metadata: { "maputnik:openmaptiles_access_token": apiKey },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("thunderforest access token", async () => {
|
||||||
|
const apiKey = "testing123";
|
||||||
|
await when.setValue("modal:settings.maputnik:thunderforest_access_token", apiKey);
|
||||||
|
await when.click("modal:settings.name");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
metadata: { "maputnik:thunderforest_access_token": apiKey },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stadia access token", async () => {
|
||||||
|
const apiKey = "testing123";
|
||||||
|
await when.setValue("modal:settings.maputnik:stadia_access_token", apiKey);
|
||||||
|
await when.click("modal:settings.name");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
metadata: { "maputnik:stadia_access_token": apiKey },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("locationiq access token", async () => {
|
||||||
|
const apiKey = "testing123";
|
||||||
|
await when.setValue("modal:settings.maputnik:locationiq_access_token", apiKey);
|
||||||
|
await when.click("modal:settings.name");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
metadata: { "maputnik:locationiq_access_token": apiKey },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("map view defaults", async () => {
|
||||||
|
await when.setValue("modal:settings.zoom", "4");
|
||||||
|
await when.setValue("modal:settings.bearing", "12");
|
||||||
|
await when.setValue("modal:settings.pitch", "30");
|
||||||
|
await when.click("modal:settings.name");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
zoom: 4,
|
||||||
|
bearing: 12,
|
||||||
|
pitch: 30,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("light intensity", async () => {
|
||||||
|
await when.setValue("modal:settings.light-intensity", "0.7");
|
||||||
|
await when.click("modal:settings.name");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
light: { intensity: 0.7 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("terrain source and exaggeration", async () => {
|
||||||
|
await when.setValue("modal:settings.maputnik:terrain_source", "terrain");
|
||||||
|
await when.setValue("modal:settings.terrain-exaggeration", "1.5");
|
||||||
|
await when.click("modal:settings.name");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
terrain: { source: "terrain", exaggeration: 1.5 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("transition delay and duration", async () => {
|
||||||
|
await when.setValue("modal:settings.transition-delay", "100");
|
||||||
|
await when.setValue("modal:settings.transition-duration", "500");
|
||||||
|
await when.click("modal:settings.name");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
transition: { delay: 100, duration: 500 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("style projection mercator", async () => {
|
||||||
|
await when.select("modal:settings.projection", "mercator");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
projection: { type: "mercator" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("style projection globe", async () => {
|
||||||
|
await when.select("modal:settings.projection", "globe");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
projection: { type: "globe" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("style projection vertical-perspective", async () => {
|
||||||
|
await when.select("modal:settings.projection", "vertical-perspective");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
projection: { type: "vertical-perspective" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("style renderer", async () => {
|
||||||
|
await when.select("modal:settings.maputnik:renderer", "ol");
|
||||||
|
await then(get.inputValue("modal:settings.maputnik:renderer")).shouldEqual("ol");
|
||||||
|
|
||||||
|
await when.click("modal:settings.name");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
metadata: { "maputnik:renderer": "ol" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("include API key when change renderer", async () => {
|
||||||
|
await when.click("modal:settings.close-modal");
|
||||||
|
await when.click("nav:open");
|
||||||
|
|
||||||
|
await when.clickByAttribute("aria-label", "MapTiler Basic");
|
||||||
|
await when.wait(1000);
|
||||||
|
await when.click("nav:settings");
|
||||||
|
|
||||||
|
await when.select("modal:settings.maputnik:renderer", "mlgljs");
|
||||||
|
await then(get.inputValue("modal:settings.maputnik:renderer")).shouldEqual("mlgljs");
|
||||||
|
|
||||||
|
await when.select("modal:settings.maputnik:renderer", "ol");
|
||||||
|
await then(get.inputValue("modal:settings.maputnik:renderer")).shouldEqual("ol");
|
||||||
|
|
||||||
|
await given.intercept(
|
||||||
|
/https:\/\/api\.maptiler\.com\/tiles\/v3-openmaptiles\/tiles\.json\?key=.*/,
|
||||||
|
"tileRequest",
|
||||||
|
"GET"
|
||||||
|
);
|
||||||
|
|
||||||
|
await when.select("modal:settings.maputnik:renderer", "mlgljs");
|
||||||
|
await then(get.inputValue("modal:settings.maputnik:renderer")).shouldEqual("mlgljs");
|
||||||
|
|
||||||
|
const request = await when.waitForResponse("tileRequest");
|
||||||
|
expect(request.url()).toContain(
|
||||||
|
`https://api.maptiler.com/tiles/v3-openmaptiles/tiles.json?key=${tokens.openmaptiles}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("add layer", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await when.setStyle("layer");
|
||||||
|
await when.modal.open();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows duplicate id error", async () => {
|
||||||
|
await when.setValue("add-layer.layer-id.input", "background");
|
||||||
|
await when.click("add-layer");
|
||||||
|
await then(get.elementByTestId("modal:add-layer")).shouldExist();
|
||||||
|
await then(get.element(".maputnik-modal-error")).shouldContainText("Layer ID already exists");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("global state", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await when.click("nav:global-state");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("add variable", async () => {
|
||||||
|
await when.wait(100);
|
||||||
|
await when.click("global-state-add-variable");
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
state: { key1: { default: "value" } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("add multiple variables", async () => {
|
||||||
|
await when.click("global-state-add-variable");
|
||||||
|
await when.click("global-state-add-variable");
|
||||||
|
await when.click("global-state-add-variable");
|
||||||
|
await when.wait(100);
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
state: { key1: { default: "value" }, key2: { default: "value" }, key3: { default: "value" } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("remove variable", async () => {
|
||||||
|
await when.click("global-state-add-variable");
|
||||||
|
await when.click("global-state-add-variable");
|
||||||
|
await when.click("global-state-add-variable");
|
||||||
|
await when.click("global-state-remove-variable", 0);
|
||||||
|
await when.wait(100);
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
state: { key2: { default: "value" }, key3: { default: "value" } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("edit variable key", async () => {
|
||||||
|
await when.click("global-state-add-variable");
|
||||||
|
await when.wait(100);
|
||||||
|
await when.setValue("global-state-variable-key:0", "mykey");
|
||||||
|
await when.typeKeys("{enter}");
|
||||||
|
await when.wait(100);
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
state: { mykey: { default: "value" } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("edit variable value", async () => {
|
||||||
|
await when.click("global-state-add-variable");
|
||||||
|
await when.wait(100);
|
||||||
|
await when.setValue("global-state-variable-value:0", "myvalue");
|
||||||
|
await when.typeKeys("{enter}");
|
||||||
|
await when.wait(100);
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
state: { key1: { default: "myvalue" } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("error panel", () => {
|
||||||
|
test("not visible when no errors", async () => {
|
||||||
|
await then(get.element("maputnik-message-panel-error")).shouldNotExist();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("visible on style error", async () => {
|
||||||
|
await when.modal.open();
|
||||||
|
await when.modal.fillLayers({
|
||||||
|
type: "circle",
|
||||||
|
layer: "invalid",
|
||||||
|
});
|
||||||
|
await then(get.element(".maputnik-message-panel-error")).shouldBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Handle localStorage QuotaExceededError", () => {
|
||||||
|
test("handles quota exceeded error when opening style from URL", async () => {
|
||||||
|
// Clear localStorage to start fresh
|
||||||
|
await when.clearLocalStorage();
|
||||||
|
await when.fillLocalStorage();
|
||||||
|
|
||||||
|
// Open the style via URL input
|
||||||
|
await when.click("nav:open");
|
||||||
|
await when.setValue("modal:open.url.input", get.exampleFileUrl());
|
||||||
|
await when.click("modal:open.url.button");
|
||||||
|
|
||||||
|
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude(get.responseBody("example-style.json"));
|
||||||
|
await then(get.styleFromLocalStorage()).shouldExist();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,426 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { expect, type Locator, type Page, type Request } from "@playwright/test";
|
||||||
|
import { currentPage, recordCoverageChunk } from "./utils/fixtures";
|
||||||
|
|
||||||
|
const DATA_ATTRIBUTE = "data-wd-key";
|
||||||
|
const FIXTURES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures");
|
||||||
|
const isMac = process.platform === "darwin";
|
||||||
|
|
||||||
|
function testIdSelector(testId: string): string {
|
||||||
|
return `[${DATA_ATTRIBUTE}="${testId}"]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retries `assertion` until it stops throwing */
|
||||||
|
async function retry(
|
||||||
|
assertion: () => Promise<void> | void,
|
||||||
|
timeout = 10000,
|
||||||
|
interval = 100
|
||||||
|
): Promise<void> {
|
||||||
|
const start = Date.now();
|
||||||
|
let lastError: unknown;
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
await assertion();
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
if (Date.now() - start > timeout) throw lastError;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, interval));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A lazily-evaluated value (e.g. the style in localStorage). Assertions on a
|
||||||
|
* Query re-read the value until they pass.
|
||||||
|
*/
|
||||||
|
class Query<T> {
|
||||||
|
readonly __query = true as const;
|
||||||
|
constructor(private readonly getter: () => Promise<T>) {}
|
||||||
|
|
||||||
|
get(): Promise<T> {
|
||||||
|
return this.getter();
|
||||||
|
}
|
||||||
|
|
||||||
|
then<U>(mapper: (value: T) => U | Promise<U>): Query<U> {
|
||||||
|
return new Query<U>(async () => mapper(await this.getter()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isQuery(target: unknown): target is Query<unknown> {
|
||||||
|
return typeof target === "object" && target !== null && (target as Query<unknown>).__query === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLocator(target: unknown): target is Locator {
|
||||||
|
return (
|
||||||
|
typeof target === "object" &&
|
||||||
|
target !== null &&
|
||||||
|
typeof (target as Locator).count === "function" &&
|
||||||
|
typeof (target as Locator).boundingBox === "function"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts that `actual` recursively contains everything in `expected`: nested
|
||||||
|
* objects are matched as subsets, while arrays and primitives must match exactly.
|
||||||
|
*/
|
||||||
|
function assertDeepNestedInclude(actual: any, expected: Record<string, unknown> | unknown[]): void {
|
||||||
|
expect(actual).toMatchObject(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fluent, auto-retrying assertions over a Playwright Locator or a lazily
|
||||||
|
* evaluated value/Query. This is the generic base that the maputnik-specific
|
||||||
|
* assertable extends.
|
||||||
|
*/
|
||||||
|
export class Assertable<T> {
|
||||||
|
constructor(private readonly target: T) {}
|
||||||
|
|
||||||
|
private locator(): Locator {
|
||||||
|
if (!isLocator(this.target)) throw new Error("Expected a Locator target for this assertion");
|
||||||
|
return this.target;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async assertValue(assertion: (value: any) => void): Promise<void> {
|
||||||
|
const target = this.target;
|
||||||
|
if (isQuery(target)) {
|
||||||
|
await retry(async () => assertion(await target.get()));
|
||||||
|
} else {
|
||||||
|
assertion(await (target as any));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Element assertions (auto-retrying via Playwright web-first assertions).
|
||||||
|
shouldBeVisible = () => expect(this.locator().first()).toBeVisible();
|
||||||
|
// Some testids resolve to many elements that are always rendered but hidden
|
||||||
|
// (e.g. per-field documentation panels); "not visible" means none is visible.
|
||||||
|
shouldNotBeVisible = () => expect(this.locator().filter({ visible: true })).toHaveCount(0);
|
||||||
|
shouldExist = async () => {
|
||||||
|
if (isLocator(this.target)) {
|
||||||
|
await expect(this.locator().first()).toBeAttached();
|
||||||
|
} else {
|
||||||
|
await this.assertValue((value) => expect(value).toBeTruthy());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
shouldNotExist = () => expect(this.locator()).toHaveCount(0);
|
||||||
|
shouldBeFocused = () => expect(this.locator().first()).toBeFocused();
|
||||||
|
shouldNotBeFocused = () => expect(this.locator().first()).not.toBeFocused();
|
||||||
|
shouldHaveValue = (value: string) => expect(this.locator().first()).toHaveValue(value);
|
||||||
|
shouldContainText = async (text: string) => {
|
||||||
|
const locator = this.locator();
|
||||||
|
// Prefer the visible element when a testid resolves to several (only the
|
||||||
|
// open documentation panel is visible; the rest are hidden in the DOM).
|
||||||
|
const target = (await locator.count()) > 1 ? locator.filter({ visible: true }).first() : locator.first();
|
||||||
|
await expect(target).toContainText(text);
|
||||||
|
};
|
||||||
|
shouldHaveText = (text: string) => expect(this.locator().first()).toHaveText(text);
|
||||||
|
shouldHaveLength = (length: number) => expect(this.locator()).toHaveCount(length);
|
||||||
|
shouldHaveCss = (property: string, value: string) => expect(this.locator().first()).toHaveCSS(property, value);
|
||||||
|
|
||||||
|
// Value assertions (auto-retrying for Query targets).
|
||||||
|
shouldEqual = (value: any) => this.assertValue((actual) => expect(actual).toBe(value));
|
||||||
|
|
||||||
|
shouldInclude = (value: any) =>
|
||||||
|
this.assertValue((actual) => {
|
||||||
|
if (typeof value === "object" && value !== null) {
|
||||||
|
expect(actual).toMatchObject(value);
|
||||||
|
} else {
|
||||||
|
expect(String(actual)).toContain(String(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
shouldDeepNestedInclude = (value: Record<string, unknown> | unknown[]) =>
|
||||||
|
this.assertValue((actual) => assertDeepNestedInclude(actual, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function typeSequence(page: Page, text: string): Promise<void> {
|
||||||
|
const tokens = text.match(/\{[^}]+\}|[^{]+/g) ?? [];
|
||||||
|
const modifierMap: Record<string, string> = { meta: "Meta", ctrl: "Control", shift: "Shift", alt: "Alt" };
|
||||||
|
const namedKeys: Record<string, string> = {
|
||||||
|
esc: "Escape",
|
||||||
|
enter: "Enter",
|
||||||
|
backspace: "Backspace",
|
||||||
|
del: "Delete",
|
||||||
|
tab: "Tab",
|
||||||
|
home: "Home",
|
||||||
|
end: "End",
|
||||||
|
rightarrow: "ArrowRight",
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < tokens.length; i++) {
|
||||||
|
const token = tokens[i];
|
||||||
|
if (!token.startsWith("{") || !token.endsWith("}")) {
|
||||||
|
await page.keyboard.type(token);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const name = token.slice(1, -1).toLowerCase();
|
||||||
|
if (name === "selectall") {
|
||||||
|
await page.keyboard.press(isMac ? "Meta+a" : "Control+a");
|
||||||
|
} else if (namedKeys[name]) {
|
||||||
|
await page.keyboard.press(namedKeys[name]);
|
||||||
|
} else if (modifierMap[name]) {
|
||||||
|
const modifiers = [modifierMap[name]];
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < tokens.length && /^\{(meta|ctrl|shift|alt)\}$/i.test(tokens[j])) {
|
||||||
|
modifiers.push(modifierMap[tokens[j].slice(1, -1).toLowerCase()]);
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
const key = tokens[j] ?? "";
|
||||||
|
await page.keyboard.press([...modifiers, key].join("+"));
|
||||||
|
i = j;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function centerOf(locator: Locator): Promise<{ x: number; y: number }> {
|
||||||
|
const box = await locator.boundingBox();
|
||||||
|
if (!box) throw new Error("Element has no bounding box");
|
||||||
|
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is where all plywright-specific test helpers live.
|
||||||
|
* It is used by the MaputnikDriver to implement the Maputnik-specific test helpers.
|
||||||
|
*/
|
||||||
|
export class PlaywrightHelper {
|
||||||
|
private readonly recordedRequests = new Map<string, Request[]>();
|
||||||
|
|
||||||
|
private get page(): Page {
|
||||||
|
return currentPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
private testId(testId: string): Locator {
|
||||||
|
return this.page.locator(testIdSelector(testId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads and parses a JSON fixture from the fixtures directory. */
|
||||||
|
public readFixture(name: string): any {
|
||||||
|
return JSON.parse(fs.readFileSync(path.join(FIXTURES_DIR, name), "utf-8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wraps a lazily-evaluated value so assertions on it auto-retry. */
|
||||||
|
public query<T>(getter: () => Promise<T>): Query<T> {
|
||||||
|
return new Query<T>(getter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stubs the File System Access "save" picker so file saves complete headlessly. */
|
||||||
|
public stubSaveFilePicker(): Promise<void> {
|
||||||
|
return this.page.evaluate(() => {
|
||||||
|
(window as any).showSaveFilePicker = async () => ({
|
||||||
|
createWritable: async () => ({ write: async () => {}, close: async () => {} }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entry point for fluent assertions over a Locator or a value/Query. */
|
||||||
|
public then = <T>(target: T): Assertable<T> => new Assertable(target);
|
||||||
|
|
||||||
|
public given = {
|
||||||
|
intercept: async (pattern: RegExp, alias: string, _method = "GET") => {
|
||||||
|
this.recordedRequests.set(alias, []);
|
||||||
|
await this.page.route(pattern, (route) => {
|
||||||
|
this.recordedRequests.get(alias)!.push(route.request());
|
||||||
|
route.continue();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
interceptAndMockResponse: async (options: {
|
||||||
|
method?: string;
|
||||||
|
url: string | RegExp;
|
||||||
|
response: unknown | { fixture: string };
|
||||||
|
alias?: string;
|
||||||
|
}) => {
|
||||||
|
const { url, response, alias } = options;
|
||||||
|
if (alias) this.recordedRequests.set(alias, []);
|
||||||
|
await this.page.route(url, (route) => {
|
||||||
|
if (alias) this.recordedRequests.get(alias)!.push(route.request());
|
||||||
|
const body =
|
||||||
|
response && typeof response === "object" && "fixture" in (response as any)
|
||||||
|
? this.readFixture((response as { fixture: string }).fixture)
|
||||||
|
: response;
|
||||||
|
route.fulfill({ json: body });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
public when = {
|
||||||
|
visit: async (url: string) => {
|
||||||
|
// Snapshot coverage before navigating, since a full page load resets it.
|
||||||
|
await recordCoverageChunk(this.page);
|
||||||
|
await this.page.goto(url);
|
||||||
|
},
|
||||||
|
|
||||||
|
wait: (ms: number) => this.page.waitForTimeout(ms),
|
||||||
|
|
||||||
|
tab: () => this.page.keyboard.press("Tab"),
|
||||||
|
|
||||||
|
typeKeys: (keys: string) => typeSequence(this.page, keys),
|
||||||
|
|
||||||
|
/** Types raw text into the focused element (no "{key}" sequence parsing). */
|
||||||
|
typeText: (text: string) => this.page.keyboard.type(text),
|
||||||
|
|
||||||
|
clickButtonByName: async (name: string) => {
|
||||||
|
await this.page.getByRole("button", { name }).click();
|
||||||
|
},
|
||||||
|
|
||||||
|
click: async (testId: string, index = 0) => {
|
||||||
|
// Documentation buttons are wrapped in a <label>/.maputnik-doc-target that
|
||||||
|
// Playwright treats as intercepting the click; bypass the check for them.
|
||||||
|
const force = testId.startsWith("field-doc-button-");
|
||||||
|
await this.testId(testId).nth(index).click({ force });
|
||||||
|
},
|
||||||
|
|
||||||
|
realClick: async (testId: string) => {
|
||||||
|
await this.testId(testId).click();
|
||||||
|
},
|
||||||
|
|
||||||
|
hover: async (testId: string) => {
|
||||||
|
await this.testId(testId).hover();
|
||||||
|
},
|
||||||
|
|
||||||
|
focus: async (testId: string) => {
|
||||||
|
await this.testId(testId).focus();
|
||||||
|
},
|
||||||
|
|
||||||
|
clear: async (testId: string) => {
|
||||||
|
await this.testId(testId).clear();
|
||||||
|
},
|
||||||
|
|
||||||
|
select: async (testId: string, value: string) => {
|
||||||
|
await this.testId(testId).selectOption(value);
|
||||||
|
},
|
||||||
|
|
||||||
|
selectWithin: async (parentTestId: string, value: string) => {
|
||||||
|
await this.testId(parentTestId).locator("select").selectOption(value);
|
||||||
|
},
|
||||||
|
|
||||||
|
clickWithin: async (parentTestId: string, selector: string) => {
|
||||||
|
await this.testId(parentTestId).locator(selector).first().click();
|
||||||
|
},
|
||||||
|
|
||||||
|
clickByText: async (text: string) => {
|
||||||
|
await this.page.getByText(text).click();
|
||||||
|
},
|
||||||
|
|
||||||
|
clickByAttribute: async (attribute: string, value: string) => {
|
||||||
|
await this.page.locator(`[${attribute}="${value}"]`).click();
|
||||||
|
},
|
||||||
|
|
||||||
|
scrollToBottom: async (element: Locator) => {
|
||||||
|
await element.evaluate((el) => el.scrollTo(0, el.scrollHeight));
|
||||||
|
},
|
||||||
|
|
||||||
|
setValue: async (testId: string, text: string) => {
|
||||||
|
const input = this.testId(testId);
|
||||||
|
await input.fill("");
|
||||||
|
await input.fill(text);
|
||||||
|
},
|
||||||
|
|
||||||
|
type: async (testId: string, text: string) => {
|
||||||
|
await this.testId(testId).focus();
|
||||||
|
// Place the caret at the start of the field, so a leading "{backspace}"
|
||||||
|
// is a no-op rather than clearing an already-committed value.
|
||||||
|
await this.page.keyboard.press("Home");
|
||||||
|
await typeSequence(this.page, text);
|
||||||
|
},
|
||||||
|
|
||||||
|
dragAndDropWithWait: async (source: string, target: string) => {
|
||||||
|
const from = await centerOf(this.testId(source));
|
||||||
|
const to = await centerOf(this.testId(target));
|
||||||
|
await this.page.mouse.move(from.x, from.y);
|
||||||
|
await this.page.mouse.down();
|
||||||
|
await this.page.mouse.move(from.x, from.y + 10);
|
||||||
|
await this.page.mouse.move(to.x, to.y, { steps: 10 });
|
||||||
|
await this.page.waitForTimeout(100);
|
||||||
|
await this.page.mouse.up();
|
||||||
|
},
|
||||||
|
|
||||||
|
clickCenter: async (testId: string) => {
|
||||||
|
const { x, y } = await centerOf(this.testId(testId));
|
||||||
|
await this.page.mouse.move(x, y);
|
||||||
|
await this.page.mouse.down();
|
||||||
|
await this.page.waitForTimeout(200);
|
||||||
|
await this.page.mouse.up();
|
||||||
|
},
|
||||||
|
|
||||||
|
openFileByFixture: async (fixture: string, buttonTestId: string, inputTestId: string) => {
|
||||||
|
const content = JSON.stringify(this.readFixture(fixture));
|
||||||
|
const hasPicker = await this.page.evaluate(() => "showOpenFilePicker" in window);
|
||||||
|
if (hasPicker) {
|
||||||
|
await this.page.evaluate((fileContent) => {
|
||||||
|
(window as any).showOpenFilePicker = async () => [
|
||||||
|
{ getFile: async () => ({ text: async () => fileContent }) },
|
||||||
|
];
|
||||||
|
}, content);
|
||||||
|
await this.testId(buttonTestId).click();
|
||||||
|
} else {
|
||||||
|
await this.testId(inputTestId).setInputFiles({
|
||||||
|
name: fixture,
|
||||||
|
mimeType: "application/json",
|
||||||
|
buffer: Buffer.from(content),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
dropFileByFixture: async (fixture: string, dropzoneTestId: string) => {
|
||||||
|
const content = JSON.stringify(this.readFixture(fixture));
|
||||||
|
const dataTransfer = await this.page.evaluateHandle((fileContent) => {
|
||||||
|
const dt = new DataTransfer();
|
||||||
|
dt.items.add(new File([fileContent], "example-style.json", { type: "application/json" }));
|
||||||
|
return dt;
|
||||||
|
}, content);
|
||||||
|
const dropzone = this.testId(dropzoneTestId);
|
||||||
|
await dropzone.dispatchEvent("dragenter", { dataTransfer });
|
||||||
|
await dropzone.dispatchEvent("dragover", { dataTransfer });
|
||||||
|
await dropzone.dispatchEvent("drop", { dataTransfer });
|
||||||
|
},
|
||||||
|
|
||||||
|
waitForResponse: async (alias: string) => {
|
||||||
|
const requests = this.recordedRequests.get(alias);
|
||||||
|
if (!requests) throw new Error(`No intercept registered for alias "${alias}"`);
|
||||||
|
await retry(async () => {
|
||||||
|
if (requests.length === 0) throw new Error(`No request recorded for alias "${alias}"`);
|
||||||
|
});
|
||||||
|
return requests[requests.length - 1];
|
||||||
|
},
|
||||||
|
|
||||||
|
clearLocalStorage: () => this.page.evaluate(() => window.localStorage.clear()),
|
||||||
|
|
||||||
|
/** Writes to localStorage under `keyPrefix` until a QuotaExceededError is hit. */
|
||||||
|
fillLocalStorageUntilQuota: (keyPrefix: string) =>
|
||||||
|
this.page.evaluate((prefix) => {
|
||||||
|
let chunkSize = 1000;
|
||||||
|
const chunk = new Array(chunkSize).join("x");
|
||||||
|
let index = 0;
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(`${prefix}${index++}`, chunk);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.name === "QuotaExceededError") {
|
||||||
|
if (chunkSize <= 1) return;
|
||||||
|
chunkSize /= 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw e; // Unexpected error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, keyPrefix),
|
||||||
|
};
|
||||||
|
|
||||||
|
public get = {
|
||||||
|
element: (selector: string) => this.page.locator(selector),
|
||||||
|
|
||||||
|
localStorageItem: (key: string) =>
|
||||||
|
this.page.evaluate((k) => window.localStorage.getItem(k), key),
|
||||||
|
|
||||||
|
elementByTestId: (testId: string) => this.testId(testId),
|
||||||
|
|
||||||
|
inputValue: (testId: string) => new Query<string>(() => this.testId(testId).first().inputValue()),
|
||||||
|
|
||||||
|
elementsText: (testId: string) => new Query<string>(() => this.testId(testId).first().innerText()),
|
||||||
|
|
||||||
|
locationHash: () => new Query<string>(async () => new URL(this.page.url()).hash),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the istanbul coverage output directory before the e2e run so stale
|
||||||
|
* coverage from previous runs is not merged into the report.
|
||||||
|
*/
|
||||||
|
export default function globalSetup(): void {
|
||||||
|
const dir = path.resolve(process.cwd(), ".nyc_output");
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merges the per-test istanbul coverage chunks collected in `.nyc_output` into a
|
||||||
|
* report (configured by `.nycrc.json`) once the whole e2e run has finished.
|
||||||
|
*/
|
||||||
|
export default function globalTeardown(): void {
|
||||||
|
const dir = path.resolve(process.cwd(), ".nyc_output");
|
||||||
|
const hasCoverage = fs.existsSync(dir) && fs.readdirSync(dir).some((f) => f.endsWith(".json"));
|
||||||
|
if (!hasCoverage) {
|
||||||
|
console.warn("No coverage data collected; skipping coverage report.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
execFileSync("npx", ["nyc", "report"], { stdio: "inherit" });
|
||||||
|
} catch (error) {
|
||||||
|
// Don't fail the whole run if the report can't be generated (e.g. when
|
||||||
|
// running against a container whose source paths differ from the host).
|
||||||
|
console.warn("Failed to generate coverage report:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { test, expect, type Page } from "@playwright/test";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
let activePage: Page | undefined;
|
||||||
|
const coverageChunks: unknown[] = [];
|
||||||
|
|
||||||
|
/** The page for the currently running test. Throws if used outside a test. */
|
||||||
|
export function currentPage(): Page {
|
||||||
|
if (!activePage) {
|
||||||
|
throw new Error("No active page: a MaputnikDriver method was called outside of a running test.");
|
||||||
|
}
|
||||||
|
return activePage;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OUTPUT_DIR = path.resolve(process.cwd(), ".nyc_output");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the istanbul coverage object (injected by vite-plugin-istanbul) from the
|
||||||
|
* given page. Returns `null` when the page has not been instrumented.
|
||||||
|
*/
|
||||||
|
async function readCoverage(page: Page): Promise<unknown | null> {
|
||||||
|
try {
|
||||||
|
return await page.evaluate(() => (window as unknown as { __coverage__?: unknown }).__coverage__ ?? null);
|
||||||
|
} catch {
|
||||||
|
// Page might be navigating/closed.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists a coverage chunk to `.nyc_output` so that `nyc report` can merge it.
|
||||||
|
* istanbul-lib-coverage (used by nyc) sums the hit counts across every file it
|
||||||
|
* finds, so writing one file per chunk is enough to accumulate coverage across
|
||||||
|
* navigations and tests.
|
||||||
|
*/
|
||||||
|
export function writeCoverage(coverage: unknown, id: string): void {
|
||||||
|
if (!coverage) return;
|
||||||
|
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(OUTPUT_DIR, `playwright-${id}.json`), JSON.stringify(coverage));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Records a coverage snapshot (called before navigations, which reset __coverage__). */
|
||||||
|
export async function recordCoverageChunk(page: Page): Promise<void> {
|
||||||
|
const chunk = await readCoverage(page);
|
||||||
|
if (chunk) coverageChunks.push(chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto fixture that binds the current test's page for the (page-lazy)
|
||||||
|
* MaputnikDriver, auto-accepts confirm dialogs, and writes the istanbul
|
||||||
|
* coverage collected during the test to `.nyc_output`.
|
||||||
|
*/
|
||||||
|
const extendedTest = test.extend<{ maputnikPage: void }>({
|
||||||
|
maputnikPage: [
|
||||||
|
async ({ page }, use, testInfo) => {
|
||||||
|
activePage = page;
|
||||||
|
coverageChunks.length = 0;
|
||||||
|
// Accept confirm dialogs (e.g. the "replace current style" prompt). These
|
||||||
|
// are dismissed by default, which would cancel loading a style via URL.
|
||||||
|
page.on("dialog", (dialog) => dialog.accept().catch(() => undefined));
|
||||||
|
|
||||||
|
await use();
|
||||||
|
|
||||||
|
const finalCoverage = await readCoverage(page);
|
||||||
|
if (finalCoverage) coverageChunks.push(finalCoverage);
|
||||||
|
coverageChunks.forEach((chunk, index) => writeCoverage(chunk, `${testInfo.testId}-${index}`));
|
||||||
|
coverageChunks.length = 0;
|
||||||
|
activePage = undefined;
|
||||||
|
},
|
||||||
|
{ auto: true },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const describe = extendedTest.describe;
|
||||||
|
const beforeEach = extendedTest.beforeEach;
|
||||||
|
export {
|
||||||
|
expect,
|
||||||
|
describe,
|
||||||
|
extendedTest as test,
|
||||||
|
beforeEach,
|
||||||
|
};
|
||||||
Generated
+884
-3110
File diff suppressed because it is too large
Load Diff
+17
-20
@@ -11,10 +11,10 @@
|
|||||||
"build-linux": "tsc && vite build --mode=desktop && cd desktop && make bin/linux/maputnik",
|
"build-linux": "tsc && vite build --mode=desktop && cd desktop && make bin/linux/maputnik",
|
||||||
"i18n:extract": "npx i18next-cli extract",
|
"i18n:extract": "npx i18next-cli extract",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"test": "cypress run",
|
"test": "playwright test",
|
||||||
|
"test-e2e": "playwright test",
|
||||||
"test-unit": "vitest",
|
"test-unit": "vitest",
|
||||||
"test-unit-ci": "vitest run --coverage --reporter=json",
|
"test-unit-ci": "vitest run --coverage --reporter=json",
|
||||||
"cy:open": "cypress open",
|
|
||||||
"lint-css": "stylelint \"src/styles/*.scss\"",
|
"lint-css": "stylelint \"src/styles/*.scss\"",
|
||||||
"sort-styles": "jq 'sort_by(.id)' src/config/styles.json > tmp.json && mv tmp.json src/config/styles.json"
|
"sort-styles": "jq 'sort_by(.id)' src/config/styles.json > tmp.json && mv tmp.json src/config/styles.json"
|
||||||
},
|
},
|
||||||
@@ -28,16 +28,16 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-json": "^6.0.2",
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
"@codemirror/lint": "^6.9.7",
|
"@codemirror/lint": "^6.9.7",
|
||||||
"@codemirror/state": "^6.7.0",
|
"@codemirror/state": "^6.7.1",
|
||||||
"@codemirror/theme-one-dark": "^6.1.3",
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"@codemirror/view": "^6.43.4",
|
"@codemirror/view": "^6.43.6",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@mapbox/mapbox-gl-rtl-text": "^0.4.0",
|
"@mapbox/mapbox-gl-rtl-text": "^0.4.0",
|
||||||
"@maplibre/maplibre-gl-geocoder": "^1.9.4",
|
"@maplibre/maplibre-gl-geocoder": "^1.9.4",
|
||||||
"@maplibre/maplibre-gl-inspect": "^1.8.2",
|
"@maplibre/maplibre-gl-inspect": "^1.8.2",
|
||||||
"@maplibre/maplibre-gl-style-spec": "^25.0.1",
|
"@maplibre/maplibre-gl-style-spec": "^25.0.2",
|
||||||
"array-move": "^4.0.0",
|
"array-move": "^4.0.0",
|
||||||
"buffer": "^6.0.3",
|
"buffer": "^6.0.3",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
@@ -65,15 +65,15 @@
|
|||||||
"ol-mapbox-style": "^13.4.1",
|
"ol-mapbox-style": "^13.4.1",
|
||||||
"pmtiles": "^4.4.1",
|
"pmtiles": "^4.4.1",
|
||||||
"prop-types": "^15.8.1",
|
"prop-types": "^15.8.1",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.7",
|
||||||
"react-accessible-accordion": "^5.0.1",
|
"react-accessible-accordion": "^5.0.1",
|
||||||
"react-aria-menubutton": "^8.0.0",
|
"react-aria-menubutton": "^8.0.0",
|
||||||
"react-aria-modal": "^5.0.2",
|
"react-aria-modal": "^5.0.2",
|
||||||
"react-collapse": "^5.1.1",
|
"react-collapse": "^5.1.1",
|
||||||
"react-color": "^2.19.3",
|
"react-color": "^2.19.3",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.7",
|
||||||
"react-i18next": "^17.0.7",
|
"react-i18next": "^17.0.8",
|
||||||
"react-icons": "^5.6.0",
|
"react-icons": "^5.7.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"reconnecting-websocket": "^4.4.0",
|
"reconnecting-websocket": "^4.4.0",
|
||||||
"slugify": "^1.6.9",
|
"slugify": "^1.6.9",
|
||||||
@@ -98,11 +98,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cypress/code-coverage": "^4.0.3",
|
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@istanbuljs/nyc-config-typescript": "^1.0.2",
|
"@istanbuljs/nyc-config-typescript": "^1.0.2",
|
||||||
"@rollup/plugin-replace": "^6.0.3",
|
"@playwright/test": "^1.61.1",
|
||||||
"@shellygo/cypress-test-utils": "^6.0.6",
|
|
||||||
"@stylistic/eslint-plugin": "^5.10.0",
|
"@stylistic/eslint-plugin": "^5.10.0",
|
||||||
"@types/codemirror": "^5.60.17",
|
"@types/codemirror": "^5.60.17",
|
||||||
"@types/color": "^4.2.1",
|
"@types/color": "^4.2.1",
|
||||||
@@ -117,7 +115,7 @@
|
|||||||
"@types/lodash.isequal": "^4.5.8",
|
"@types/lodash.isequal": "^4.5.8",
|
||||||
"@types/lodash.throttle": "^4.1.9",
|
"@types/lodash.throttle": "^4.1.9",
|
||||||
"@types/randomcolor": "^0.5.9",
|
"@types/randomcolor": "^0.5.9",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-aria-menubutton": "^6.2.14",
|
"@types/react-aria-menubutton": "^6.2.14",
|
||||||
"@types/react-aria-modal": "^5.0.0",
|
"@types/react-aria-modal": "^5.0.0",
|
||||||
"@types/react-collapse": "^5.0.4",
|
"@types/react-collapse": "^5.0.4",
|
||||||
@@ -125,18 +123,17 @@
|
|||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@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.1.4",
|
"@vitejs/plugin-react": "5.2",
|
||||||
"@vitest/coverage-v8": "^4.1.9",
|
"@vitest/coverage-v8": "^4.1.10",
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"cypress": "^15.18.0",
|
|
||||||
"cypress-plugin-tab": "^2.0.0",
|
|
||||||
"eslint": "^10.6.0",
|
"eslint": "^10.6.0",
|
||||||
"eslint-plugin-react": "^7.37.5",
|
"eslint-plugin-react": "^7.37.5",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.3",
|
||||||
"i18next-cli": "^1.53.2",
|
"i18next-cli": "^1.65.0",
|
||||||
"istanbul": "^0.4.5",
|
"istanbul": "^0.4.5",
|
||||||
"istanbul-lib-coverage": "^3.2.2",
|
"istanbul-lib-coverage": "^3.2.2",
|
||||||
|
"nyc": "^18.0.0",
|
||||||
"postcss": "^8.5.16",
|
"postcss": "^8.5.16",
|
||||||
"react-hot-loader": "^4.13.1",
|
"react-hot-loader": "^4.13.1",
|
||||||
"sass": "^1.101.0",
|
"sass": "^1.101.0",
|
||||||
@@ -148,6 +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.9"
|
"vitest": "^4.1.10"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
const isCI = !!process.env.CI;
|
||||||
|
// When the app is already served elsewhere (e.g. the docker e2e job) set
|
||||||
|
// E2E_NO_WEBSERVER=1 so Playwright does not start its own dev server.
|
||||||
|
const useExternalServer = !!process.env.E2E_NO_WEBSERVER;
|
||||||
|
const baseURL = process.env.E2E_BASE_URL ?? "http://localhost:8888/";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./e2e",
|
||||||
|
testMatch: "**/*.spec.ts",
|
||||||
|
globalSetup: "./e2e/utils/e2e-setup.ts",
|
||||||
|
globalTeardown: "./e2e/utils/e2e-teardown.ts",
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: isCI,
|
||||||
|
retries: isCI ? 2 : 0,
|
||||||
|
reporter: isCI ? [["list"], ["html", { open: "never" }]] : "list",
|
||||||
|
use: {
|
||||||
|
baseURL,
|
||||||
|
trace: "on-first-retry",
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: "chromium",
|
||||||
|
use: {
|
||||||
|
...devices["Desktop Chrome"],
|
||||||
|
launchOptions: {
|
||||||
|
// Allow WebGL (maplibre) to fall back to software rendering in headless.
|
||||||
|
args: [
|
||||||
|
"--disable-gpu",
|
||||||
|
"--enable-features=AllowSwiftShaderFallback,AllowSoftwareGLFallbackDueToCrashes",
|
||||||
|
"--enable-unsafe-swiftshader",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
webServer: useExternalServer
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
command: "npm run start",
|
||||||
|
url: "http://localhost:8888/maputnik/",
|
||||||
|
reuseExistingServer: !isCI,
|
||||||
|
timeout: 120000,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -29,7 +29,7 @@ import ModalDebug from "./modals/ModalDebug";
|
|||||||
import ModalGlobalState from "./modals/ModalGlobalState";
|
import ModalGlobalState from "./modals/ModalGlobalState";
|
||||||
|
|
||||||
import {downloadGlyphsMetadata, downloadSpriteMetadata} from "../libs/metadata";
|
import {downloadGlyphsMetadata, downloadSpriteMetadata} from "../libs/metadata";
|
||||||
import style from "../libs/style";
|
import { emptyStyle, getAccessToken, replaceAccessTokens } from "../libs/style";
|
||||||
import { undoMessages, redoMessages } from "../libs/diffmessage";
|
import { undoMessages, redoMessages } from "../libs/diffmessage";
|
||||||
import { createStyleStore, type IStyleStore } from "../libs/store/style-store-factory";
|
import { createStyleStore, type IStyleStore } from "../libs/store/style-store-factory";
|
||||||
import { RevisionStore } from "../libs/revisions";
|
import { RevisionStore } from "../libs/revisions";
|
||||||
@@ -48,19 +48,19 @@ function setFetchAccessToken(url: string, mapStyle: StyleSpecification) {
|
|||||||
const matchesThunderforest = url.match(/\.thunderforest\.com/);
|
const matchesThunderforest = url.match(/\.thunderforest\.com/);
|
||||||
const matchesLocationIQ = url.match(/\.locationiq\.com/);
|
const matchesLocationIQ = url.match(/\.locationiq\.com/);
|
||||||
if (matchesTilehosting || matchesMaptiler) {
|
if (matchesTilehosting || matchesMaptiler) {
|
||||||
const accessToken = style.getAccessToken("openmaptiles", mapStyle, {allowFallback: true});
|
const accessToken = getAccessToken("openmaptiles", mapStyle, {allowFallback: true});
|
||||||
if (accessToken) {
|
if (accessToken) {
|
||||||
return url.replace("{key}", accessToken);
|
return url.replace("{key}", accessToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (matchesThunderforest) {
|
else if (matchesThunderforest) {
|
||||||
const accessToken = style.getAccessToken("thunderforest", mapStyle, {allowFallback: true});
|
const accessToken = getAccessToken("thunderforest", mapStyle, {allowFallback: true});
|
||||||
if (accessToken) {
|
if (accessToken) {
|
||||||
return url.replace("{key}", accessToken);
|
return url.replace("{key}", accessToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (matchesLocationIQ) {
|
else if (matchesLocationIQ) {
|
||||||
const accessToken = style.getAccessToken("locationiq", mapStyle, {allowFallback: true});
|
const accessToken = getAccessToken("locationiq", mapStyle, {allowFallback: true});
|
||||||
if (accessToken) {
|
if (accessToken) {
|
||||||
return url.replace("{key}", accessToken);
|
return url.replace("{key}", accessToken);
|
||||||
}
|
}
|
||||||
@@ -137,7 +137,7 @@ export default class App extends React.Component<any, AppState> {
|
|||||||
this.state = {
|
this.state = {
|
||||||
errors: [],
|
errors: [],
|
||||||
infos: [],
|
infos: [],
|
||||||
mapStyle: style.emptyStyle,
|
mapStyle: emptyStyle,
|
||||||
selectedLayerIndex: 0,
|
selectedLayerIndex: 0,
|
||||||
sources: {},
|
sources: {},
|
||||||
vectorLayers: {},
|
vectorLayers: {},
|
||||||
@@ -698,7 +698,7 @@ export default class App extends React.Component<any, AppState> {
|
|||||||
mapStyle: (dirtyMapStyle || mapStyle),
|
mapStyle: (dirtyMapStyle || mapStyle),
|
||||||
mapView: this.state.mapView,
|
mapView: this.state.mapView,
|
||||||
replaceAccessTokens: (mapStyle: StyleSpecification) => {
|
replaceAccessTokens: (mapStyle: StyleSpecification) => {
|
||||||
return style.replaceAccessTokens(mapStyle, {
|
return replaceAccessTokens(mapStyle, {
|
||||||
allowFallback: true
|
allowFallback: true
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -215,6 +215,7 @@ class FilterEditorInternal extends React.Component<FilterEditorInternalProps, Fi
|
|||||||
onClick={this.makeExpression}
|
onClick={this.makeExpression}
|
||||||
title={t("Convert to expression")}
|
title={t("Convert to expression")}
|
||||||
className="maputnik-make-zoom-function"
|
className="maputnik-make-zoom-function"
|
||||||
|
data-wd-key="filter-convert-to-expression"
|
||||||
>
|
>
|
||||||
<TbMathFunction />
|
<TbMathFunction />
|
||||||
</InputButton>
|
</InputButton>
|
||||||
@@ -248,6 +249,7 @@ class FilterEditorInternal extends React.Component<FilterEditorInternalProps, Fi
|
|||||||
fieldSpec={fieldSpec}
|
fieldSpec={fieldSpec}
|
||||||
label={t("Filter")}
|
label={t("Filter")}
|
||||||
action={actions}
|
action={actions}
|
||||||
|
data-wd-key="filter-combining-operator"
|
||||||
>
|
>
|
||||||
<InputSelect
|
<InputSelect
|
||||||
value={combiningOp}
|
value={combiningOp}
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
import InputAutocomplete from "./InputAutocomplete";
|
|
||||||
import { mount } from "cypress/react";
|
|
||||||
|
|
||||||
const fruits = ["apple", "banana", "cherry"];
|
|
||||||
|
|
||||||
describe("<InputAutocomplete />", () => {
|
|
||||||
it("filters options when typing", () => {
|
|
||||||
mount(
|
|
||||||
<InputAutocomplete aria-label="Fruit" options={fruits.map(f => [f, f])} />
|
|
||||||
);
|
|
||||||
cy.get("input").focus();
|
|
||||||
cy.get(".maputnik-autocomplete-menu-item").should("have.length", 3);
|
|
||||||
cy.get("input").type("ch");
|
|
||||||
cy.get(".maputnik-autocomplete-menu-item").should("have.length", 1).and("contain", "cherry");
|
|
||||||
cy.get(".maputnik-autocomplete-menu-item").click();
|
|
||||||
cy.get("input").should("have.value", "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}
|
||||||
|
|||||||
@@ -289,6 +289,7 @@ class DataPropertyInternal extends React.Component<DataPropertyInternalProps, Da
|
|||||||
<Block
|
<Block
|
||||||
label={t("Function")}
|
label={t("Function")}
|
||||||
key="function"
|
key="function"
|
||||||
|
data-wd-key="function-type"
|
||||||
>
|
>
|
||||||
<div className="maputnik-data-spec-property-input">
|
<div className="maputnik-data-spec-property-input">
|
||||||
<InputSelect
|
<InputSelect
|
||||||
@@ -303,6 +304,7 @@ class DataPropertyInternal extends React.Component<DataPropertyInternalProps, Da
|
|||||||
<Block
|
<Block
|
||||||
label={t("Base")}
|
label={t("Base")}
|
||||||
key="base"
|
key="base"
|
||||||
|
data-wd-key="function-base"
|
||||||
>
|
>
|
||||||
<div className="maputnik-data-spec-property-input">
|
<div className="maputnik-data-spec-property-input">
|
||||||
<InputSpec
|
<InputSpec
|
||||||
@@ -317,6 +319,7 @@ class DataPropertyInternal extends React.Component<DataPropertyInternalProps, Da
|
|||||||
<Block
|
<Block
|
||||||
label={"Property"}
|
label={"Property"}
|
||||||
key="property"
|
key="property"
|
||||||
|
data-wd-key="function-property"
|
||||||
>
|
>
|
||||||
<div className="maputnik-data-spec-property-input">
|
<div className="maputnik-data-spec-property-input">
|
||||||
<InputString
|
<InputString
|
||||||
@@ -330,6 +333,7 @@ class DataPropertyInternal extends React.Component<DataPropertyInternalProps, Da
|
|||||||
<Block
|
<Block
|
||||||
label={t("Default")}
|
label={t("Default")}
|
||||||
key="default"
|
key="default"
|
||||||
|
data-wd-key="function-default"
|
||||||
>
|
>
|
||||||
<InputSpec
|
<InputSpec
|
||||||
fieldName={this.props.fieldName}
|
fieldName={this.props.fieldName}
|
||||||
@@ -368,6 +372,7 @@ class DataPropertyInternal extends React.Component<DataPropertyInternalProps, Da
|
|||||||
}
|
}
|
||||||
<InputButton
|
<InputButton
|
||||||
className="maputnik-add-stop"
|
className="maputnik-add-stop"
|
||||||
|
data-wd-key="convert-to-expression"
|
||||||
onClick={this.props.onExpressionClick?.bind(this)}
|
onClick={this.props.onExpressionClick?.bind(this)}
|
||||||
>
|
>
|
||||||
<TbMathFunction style={{ verticalAlign: "text-bottom" }} />
|
<TbMathFunction style={{ verticalAlign: "text-bottom" }} />
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ class ExpressionPropertyInternal extends React.Component<ExpressionPropertyInter
|
|||||||
onClick={this.props.onUndo}
|
onClick={this.props.onUndo}
|
||||||
disabled={undoDisabled}
|
disabled={undoDisabled}
|
||||||
className="maputnik-delete-stop"
|
className="maputnik-delete-stop"
|
||||||
|
data-wd-key="undo-expression"
|
||||||
title={t("Revert from expression")}
|
title={t("Revert from expression")}
|
||||||
>
|
>
|
||||||
<MdUndo />
|
<MdUndo />
|
||||||
@@ -59,6 +60,7 @@ class ExpressionPropertyInternal extends React.Component<ExpressionPropertyInter
|
|||||||
key="delete_action"
|
key="delete_action"
|
||||||
onClick={this.props.onDelete}
|
onClick={this.props.onDelete}
|
||||||
className="maputnik-delete-stop"
|
className="maputnik-delete-stop"
|
||||||
|
data-wd-key="delete-expression"
|
||||||
title={t("Delete expression")}
|
title={t("Delete expression")}
|
||||||
>
|
>
|
||||||
<MdDelete />
|
<MdDelete />
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ class ZoomPropertyInternal extends React.Component<ZoomPropertyInternalProps, Zo
|
|||||||
<div className="maputnik-data-fieldset-inner">
|
<div className="maputnik-data-fieldset-inner">
|
||||||
<Block
|
<Block
|
||||||
label={t("Function")}
|
label={t("Function")}
|
||||||
|
data-wd-key="function-type"
|
||||||
>
|
>
|
||||||
<div className="maputnik-data-spec-property-input">
|
<div className="maputnik-data-spec-property-input">
|
||||||
<InputSelect
|
<InputSelect
|
||||||
@@ -206,6 +207,7 @@ class ZoomPropertyInternal extends React.Component<ZoomPropertyInternalProps, Zo
|
|||||||
</Block>
|
</Block>
|
||||||
<Block
|
<Block
|
||||||
label={t("Base")}
|
label={t("Base")}
|
||||||
|
data-wd-key="function-base"
|
||||||
>
|
>
|
||||||
<div className="maputnik-data-spec-property-input">
|
<div className="maputnik-data-spec-property-input">
|
||||||
<InputSpec
|
<InputSpec
|
||||||
@@ -240,6 +242,7 @@ class ZoomPropertyInternal extends React.Component<ZoomPropertyInternalProps, Zo
|
|||||||
</InputButton>
|
</InputButton>
|
||||||
<InputButton
|
<InputButton
|
||||||
className="maputnik-add-stop"
|
className="maputnik-add-stop"
|
||||||
|
data-wd-key="convert-to-expression"
|
||||||
onClick={this.props.onExpressionClick?.bind(this)}
|
onClick={this.props.onExpressionClick?.bind(this)}
|
||||||
>
|
>
|
||||||
<TbMathFunction style={{ verticalAlign: "text-bottom" }} />
|
<TbMathFunction style={{ verticalAlign: "text-bottom" }} />
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {type WithTranslation, withTranslation} from "react-i18next";
|
|||||||
import FieldString from "../FieldString";
|
import FieldString from "../FieldString";
|
||||||
import InputButton from "../InputButton";
|
import InputButton from "../InputButton";
|
||||||
import Modal from "./Modal";
|
import Modal from "./Modal";
|
||||||
import style from "../../libs/style";
|
import {replaceAccessTokens, stripAccessTokens} from "../../libs/style";
|
||||||
import fieldSpecAdditional from "../../libs/field-spec-additional";
|
import fieldSpecAdditional from "../../libs/field-spec-additional";
|
||||||
import type {OnStyleChangedCallback, StyleSpecificationWithId} from "../../libs/definitions";
|
import type {OnStyleChangedCallback, StyleSpecificationWithId} from "../../libs/definitions";
|
||||||
|
|
||||||
@@ -32,8 +32,8 @@ class ModalExportInternal extends React.Component<ModalExportInternalProps> {
|
|||||||
|
|
||||||
tokenizedStyle() {
|
tokenizedStyle() {
|
||||||
return format(
|
return format(
|
||||||
style.stripAccessTokens(
|
stripAccessTokens(
|
||||||
style.replaceAccessTokens(this.props.mapStyle)
|
replaceAccessTokens(this.props.mapStyle)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import Modal from "./Modal";
|
|||||||
import InputButton from "../InputButton";
|
import InputButton from "../InputButton";
|
||||||
import InputUrl from "../InputUrl";
|
import InputUrl from "../InputUrl";
|
||||||
|
|
||||||
import style from "../../libs/style";
|
import { ensureStyleValidity } from "../../libs/style";
|
||||||
import publicStyles from "../../config/styles.json";
|
import publicStyles from "../../config/styles.json";
|
||||||
|
|
||||||
type PublicStyleProps = {
|
type PublicStyleProps = {
|
||||||
@@ -109,7 +109,7 @@ class ModalOpenInternal extends React.Component<ModalOpenInternalProps, ModalOpe
|
|||||||
activeRequestUrl: null
|
activeRequestUrl: null
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapStyle = style.ensureStyleValidity(body);
|
const mapStyle = ensureStyleValidity(body);
|
||||||
console.log("Loaded style ", mapStyle.id);
|
console.log("Loaded style ", mapStyle.id);
|
||||||
this.props.onStyleOpen(mapStyle);
|
this.props.onStyleOpen(mapStyle);
|
||||||
this.onOpenToggle();
|
this.onOpenToggle();
|
||||||
@@ -165,7 +165,7 @@ class ModalOpenInternal extends React.Component<ModalOpenInternalProps, ModalOpe
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
mapStyle = style.ensureStyleValidity(mapStyle);
|
mapStyle = ensureStyleValidity(mapStyle);
|
||||||
|
|
||||||
this.props.onStyleOpen(mapStyle, fileHandle);
|
this.props.onStyleOpen(mapStyle, fileHandle);
|
||||||
this.onOpenToggle();
|
this.onOpenToggle();
|
||||||
@@ -193,7 +193,7 @@ class ModalOpenInternal extends React.Component<ModalOpenInternalProps, ModalOpe
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
mapStyle = style.ensureStyleValidity(mapStyle);
|
mapStyle = ensureStyleValidity(mapStyle);
|
||||||
this.props.onStyleOpen(mapStyle);
|
this.props.onStyleOpen(mapStyle);
|
||||||
this.onOpenToggle();
|
this.onOpenToggle();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -206,6 +206,7 @@ class ModalSettingsInternal extends React.Component<ModalSettingsInternalProps>
|
|||||||
|
|
||||||
<FieldNumber
|
<FieldNumber
|
||||||
label={t("Zoom")}
|
label={t("Zoom")}
|
||||||
|
data-wd-key="modal:settings.zoom"
|
||||||
fieldSpec={latest.$root.zoom}
|
fieldSpec={latest.$root.zoom}
|
||||||
value={mapStyle.zoom}
|
value={mapStyle.zoom}
|
||||||
default={0}
|
default={0}
|
||||||
@@ -214,6 +215,7 @@ class ModalSettingsInternal extends React.Component<ModalSettingsInternalProps>
|
|||||||
|
|
||||||
<FieldNumber
|
<FieldNumber
|
||||||
label={t("Bearing")}
|
label={t("Bearing")}
|
||||||
|
data-wd-key="modal:settings.bearing"
|
||||||
fieldSpec={latest.$root.bearing}
|
fieldSpec={latest.$root.bearing}
|
||||||
value={mapStyle.bearing}
|
value={mapStyle.bearing}
|
||||||
default={latest.$root.bearing.default}
|
default={latest.$root.bearing.default}
|
||||||
@@ -222,6 +224,7 @@ class ModalSettingsInternal extends React.Component<ModalSettingsInternalProps>
|
|||||||
|
|
||||||
<FieldNumber
|
<FieldNumber
|
||||||
label={t("Pitch")}
|
label={t("Pitch")}
|
||||||
|
data-wd-key="modal:settings.pitch"
|
||||||
fieldSpec={latest.$root.pitch}
|
fieldSpec={latest.$root.pitch}
|
||||||
value={mapStyle.pitch}
|
value={mapStyle.pitch}
|
||||||
default={latest.$root.pitch.default}
|
default={latest.$root.pitch.default}
|
||||||
@@ -248,6 +251,7 @@ class ModalSettingsInternal extends React.Component<ModalSettingsInternalProps>
|
|||||||
|
|
||||||
<FieldNumber
|
<FieldNumber
|
||||||
label={t("Light intensity")}
|
label={t("Light intensity")}
|
||||||
|
data-wd-key="modal:settings.light-intensity"
|
||||||
fieldSpec={latest.light.intensity}
|
fieldSpec={latest.light.intensity}
|
||||||
value={light.intensity as number}
|
value={light.intensity as number}
|
||||||
default={latest.light.intensity.default}
|
default={latest.light.intensity.default}
|
||||||
@@ -274,6 +278,7 @@ class ModalSettingsInternal extends React.Component<ModalSettingsInternalProps>
|
|||||||
|
|
||||||
<FieldNumber
|
<FieldNumber
|
||||||
label={t("Terrain exaggeration")}
|
label={t("Terrain exaggeration")}
|
||||||
|
data-wd-key="modal:settings.terrain-exaggeration"
|
||||||
fieldSpec={latest.terrain.exaggeration}
|
fieldSpec={latest.terrain.exaggeration}
|
||||||
value={terrain.exaggeration}
|
value={terrain.exaggeration}
|
||||||
default={latest.terrain.exaggeration.default}
|
default={latest.terrain.exaggeration.default}
|
||||||
@@ -282,6 +287,7 @@ class ModalSettingsInternal extends React.Component<ModalSettingsInternalProps>
|
|||||||
|
|
||||||
<FieldNumber
|
<FieldNumber
|
||||||
label={t("Transition delay")}
|
label={t("Transition delay")}
|
||||||
|
data-wd-key="modal:settings.transition-delay"
|
||||||
fieldSpec={latest.transition.delay}
|
fieldSpec={latest.transition.delay}
|
||||||
value={transition.delay}
|
value={transition.delay}
|
||||||
default={latest.transition.delay.default}
|
default={latest.transition.delay.default}
|
||||||
@@ -290,6 +296,7 @@ class ModalSettingsInternal extends React.Component<ModalSettingsInternalProps>
|
|||||||
|
|
||||||
<FieldNumber
|
<FieldNumber
|
||||||
label={t("Transition duration")}
|
label={t("Transition duration")}
|
||||||
|
data-wd-key="modal:settings.transition-duration"
|
||||||
fieldSpec={latest.transition.duration}
|
fieldSpec={latest.transition.duration}
|
||||||
value={transition.duration}
|
value={transition.duration}
|
||||||
default={latest.transition.duration.default}
|
default={latest.transition.duration.default}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import FieldString from "../FieldString";
|
|||||||
import FieldSelect from "../FieldSelect";
|
import FieldSelect from "../FieldSelect";
|
||||||
import ModalSourcesTypeEditor, { type EditorMode } from "./ModalSourcesTypeEditor";
|
import ModalSourcesTypeEditor, { type EditorMode } from "./ModalSourcesTypeEditor";
|
||||||
|
|
||||||
import style from "../../libs/style";
|
import { generateId } from "../../libs/style";
|
||||||
import { deleteSource, addSource, changeSource } from "../../libs/source";
|
import { deleteSource, addSource, changeSource } from "../../libs/source";
|
||||||
import publicSources from "../../config/tilesets.json";
|
import publicSources from "../../config/tilesets.json";
|
||||||
import { type OnStyleChangedCallback, type StyleSpecificationWithId } from "../../libs/definitions";
|
import { type OnStyleChangedCallback, type StyleSpecificationWithId } from "../../libs/definitions";
|
||||||
@@ -121,7 +121,7 @@ class AddSource extends React.Component<AddSourceProps, AddSourceState> {
|
|||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
mode: "tilejson_vector",
|
mode: "tilejson_vector",
|
||||||
sourceId: style.generateId(),
|
sourceId: generateId(),
|
||||||
source: this.defaultSource("tilejson_vector"),
|
source: this.defaultSource("tilejson_vector"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import type { LayerSpecification } from "maplibre-gl";
|
||||||
|
import { colorHighlightedLayer } from "./highlight";
|
||||||
|
|
||||||
|
function layer(overrides: Record<string, any>): LayerSpecification {
|
||||||
|
return { id: "l", source: "s", "source-layer": "sl", ...overrides } as unknown as LayerSpecification;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("colorHighlightedLayer", () => {
|
||||||
|
it("returns null for undefined, background and raster layers", () => {
|
||||||
|
expect(colorHighlightedLayer(undefined)).toBeNull();
|
||||||
|
expect(colorHighlightedLayer(layer({ type: "background" }))).toBeNull();
|
||||||
|
expect(colorHighlightedLayer(layer({ type: "raster" }))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds a circle highlight for circle and symbol layers", () => {
|
||||||
|
for (const type of ["circle", "symbol"]) {
|
||||||
|
const highlight = colorHighlightedLayer(layer({ type }))!;
|
||||||
|
expect(highlight).not.toBeNull();
|
||||||
|
expect(highlight.type).toBe("circle");
|
||||||
|
expect(highlight.id).toMatch(/_highlight$/);
|
||||||
|
expect((highlight.paint as any)["circle-radius"]).toBe(3);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds a line highlight with an overridden width", () => {
|
||||||
|
const highlight = colorHighlightedLayer(layer({ type: "line" }))!;
|
||||||
|
expect(highlight.type).toBe("line");
|
||||||
|
expect((highlight.paint as any)["line-width"]).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds a polygon highlight for fill and fill-extrusion layers", () => {
|
||||||
|
for (const type of ["fill", "fill-extrusion"]) {
|
||||||
|
const highlight = colorHighlightedLayer(layer({ type }))!;
|
||||||
|
expect(highlight.type).toBe("fill");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copies the source layer's filter when present, drops it otherwise", () => {
|
||||||
|
const withFilter = colorHighlightedLayer(
|
||||||
|
layer({ type: "line", filter: ["==", "class", "road"] } as any)
|
||||||
|
)!;
|
||||||
|
expect(withFilter.filter).toEqual(["==", "class", "road"]);
|
||||||
|
|
||||||
|
const withoutFilter = colorHighlightedLayer(layer({ type: "line" }))!;
|
||||||
|
expect("filter" in withoutFilter).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import type { LayerSpecification } from "maplibre-gl";
|
||||||
|
import { changeType, changeProperty, layerPrefix, findClosestCommonPrefix } from "./layer";
|
||||||
|
|
||||||
|
describe("changeType", () => {
|
||||||
|
it("drops paint/layout props not valid for the new type", () => {
|
||||||
|
const layer = {
|
||||||
|
id: "l",
|
||||||
|
type: "fill",
|
||||||
|
source: "s",
|
||||||
|
paint: { "fill-color": "#fff", "fill-opacity": 0.5 },
|
||||||
|
layout: { visibility: "visible" },
|
||||||
|
} as unknown as LayerSpecification;
|
||||||
|
|
||||||
|
const changed = changeType(layer, "background") as any;
|
||||||
|
expect(changed.type).toBe("background");
|
||||||
|
// fill-color is not a valid background paint property
|
||||||
|
expect(changed.paint["fill-color"]).toBeUndefined();
|
||||||
|
// visibility is valid for background layout
|
||||||
|
expect(changed.layout.visibility).toBe("visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps props that are valid for the new type", () => {
|
||||||
|
const layer = {
|
||||||
|
id: "l",
|
||||||
|
type: "line",
|
||||||
|
source: "s",
|
||||||
|
paint: { "line-color": "#000" },
|
||||||
|
} as unknown as LayerSpecification;
|
||||||
|
const changed = changeType(layer, "line") as any;
|
||||||
|
expect(changed.paint["line-color"]).toBe("#000");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("changeProperty", () => {
|
||||||
|
const base = { id: "l", type: "background" } as unknown as LayerSpecification;
|
||||||
|
|
||||||
|
it("sets a top-level property", () => {
|
||||||
|
const changed = changeProperty(base, null, "minzoom", 4) as any;
|
||||||
|
expect(changed.minzoom).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes a top-level property when value is undefined", () => {
|
||||||
|
const changed = changeProperty({ ...base, minzoom: 4 } as any, null, "minzoom", undefined) as any;
|
||||||
|
expect("minzoom" in changed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets a grouped property", () => {
|
||||||
|
const changed = changeProperty(base, "paint", "background-color", "#fff") as any;
|
||||||
|
expect(changed.paint["background-color"]).toBe("#fff");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes a grouped property and drops the empty group", () => {
|
||||||
|
const layer = { ...base, paint: { "background-color": "#fff" } } as any;
|
||||||
|
const changed = changeProperty(layer, "paint", "background-color", undefined) as any;
|
||||||
|
expect("paint" in changed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes a grouped property but keeps a non-empty group", () => {
|
||||||
|
const layer = { ...base, paint: { "background-color": "#fff", "background-opacity": 1 } } as any;
|
||||||
|
const changed = changeProperty(layer, "paint", "background-color", undefined) as any;
|
||||||
|
expect(changed.paint["background-color"]).toBeUndefined();
|
||||||
|
expect(changed.paint["background-opacity"]).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("layerPrefix", () => {
|
||||||
|
it("returns the segment before the first separator", () => {
|
||||||
|
expect(layerPrefix("foo-bar")).toBe("foo");
|
||||||
|
expect(layerPrefix("foo_bar")).toBe("foo");
|
||||||
|
expect(layerPrefix("foo bar")).toBe("foo");
|
||||||
|
expect(layerPrefix("single")).toBe("single");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findClosestCommonPrefix", () => {
|
||||||
|
const layers = [
|
||||||
|
{ id: "aa" },
|
||||||
|
{ id: "aa-2" },
|
||||||
|
{ id: "b" },
|
||||||
|
] as unknown as LayerSpecification[];
|
||||||
|
|
||||||
|
it("finds the first index of a run sharing the prefix", () => {
|
||||||
|
expect(findClosestCommonPrefix(layers, 1)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the index itself when the previous prefix differs", () => {
|
||||||
|
expect(findClosestCommonPrefix(layers, 2)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the index for the first layer", () => {
|
||||||
|
expect(findClosestCommonPrefix(layers, 0)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import type { Map } from "maplibre-gl";
|
||||||
|
import LayerWatcher from "./layerwatcher";
|
||||||
|
|
||||||
|
function mockMap(): Map {
|
||||||
|
return {
|
||||||
|
style: {
|
||||||
|
tileManagers: {
|
||||||
|
vector: { _source: { vectorLayerIds: ["water", "roads"] } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
querySourceFeatures: () => [
|
||||||
|
{ properties: { class: "river", name: "A" } },
|
||||||
|
{ properties: { class: "canal" } },
|
||||||
|
],
|
||||||
|
} as unknown as Map;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("LayerWatcher", () => {
|
||||||
|
it("reports sources discovered on the map", () => {
|
||||||
|
const onSourcesChange = vi.fn();
|
||||||
|
const watcher = new LayerWatcher({ onSourcesChange });
|
||||||
|
|
||||||
|
watcher.analyzeMap(mockMap());
|
||||||
|
expect(onSourcesChange).toHaveBeenCalledOnce();
|
||||||
|
expect(watcher.sources).toEqual({ vector: ["water", "roads"] });
|
||||||
|
|
||||||
|
// Re-analyzing the same sources does not fire the callback again.
|
||||||
|
watcher.analyzeMap(mockMap());
|
||||||
|
expect(onSourcesChange).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collects vector layer field values from source features", () => {
|
||||||
|
const onVectorLayersChange = vi.fn();
|
||||||
|
const watcher = new LayerWatcher({ onVectorLayersChange });
|
||||||
|
|
||||||
|
const map = mockMap();
|
||||||
|
watcher.analyzeMap(map); // populates _sources
|
||||||
|
watcher.analyzeVectorLayerFields(map);
|
||||||
|
|
||||||
|
expect(onVectorLayersChange).toHaveBeenCalled();
|
||||||
|
expect(watcher.vectorLayers.water.class).toHaveProperty("river");
|
||||||
|
expect(watcher.vectorLayers.water.class).toHaveProperty("canal");
|
||||||
|
expect(watcher.vectorLayers.water.name).toHaveProperty("A");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import sortNumerically from "./sort-numerically";
|
||||||
|
|
||||||
|
describe("sortNumerically", () => {
|
||||||
|
it("orders numbers (and numeric strings) ascending", () => {
|
||||||
|
expect([3, 1, 2].sort(sortNumerically)).toEqual([1, 2, 3]);
|
||||||
|
expect(["10", "2", "1"].sort(sortNumerically)).toEqual(["1", "2", "10"]);
|
||||||
|
expect(sortNumerically(5, 5)).toBe(0);
|
||||||
|
expect(sortNumerically(1, 2)).toBe(-1);
|
||||||
|
expect(sortNumerically(2, 1)).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { findDefaultFromSpec } from "./spec-helper";
|
||||||
|
|
||||||
|
describe("findDefaultFromSpec", () => {
|
||||||
|
it("returns the spec default when present (even if falsy)", () => {
|
||||||
|
expect(findDefaultFromSpec({ type: "string", default: "hi" })).toBe("hi");
|
||||||
|
expect(findDefaultFromSpec({ type: "boolean", default: false })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to a sensible default per type", () => {
|
||||||
|
expect(findDefaultFromSpec({ type: "color" })).toBe("#000000");
|
||||||
|
expect(findDefaultFromSpec({ type: "string" })).toBe("");
|
||||||
|
// The falsy `false` fallback collapses to "" via the `|| ""` in the impl.
|
||||||
|
expect(findDefaultFromSpec({ type: "boolean" })).toBe("");
|
||||||
|
expect(findDefaultFromSpec({ type: "array" })).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.stubGlobal("window", { location: { port: "8000" } });
|
||||||
|
|
||||||
|
// Avoid opening a real websocket in notifyLocalChanges().
|
||||||
|
const wsInstances: any[] = [];
|
||||||
|
vi.mock("reconnecting-websocket", () => ({
|
||||||
|
default: class {
|
||||||
|
onmessage: ((e: { data: string }) => void) | null = null;
|
||||||
|
constructor(public url: string) {
|
||||||
|
wsInstances.push(this);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ApiStyleStore } from "./apistore";
|
||||||
|
|
||||||
|
const okJson = (body: any) => ({ ok: true, json: async () => body });
|
||||||
|
|
||||||
|
describe("ApiStyleStore", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
wsInstances.length = 0;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("init fetches style ids and wires up local change notifications", async () => {
|
||||||
|
const fetchMock = vi.fn(async () => okJson(["style-a", "style-b"]) as any);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
const onLocalStyleChange = vi.fn();
|
||||||
|
const store = new ApiStyleStore({ onLocalStyleChange });
|
||||||
|
|
||||||
|
await store.init();
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith("http://localhost:8000/styles", { mode: "cors" });
|
||||||
|
expect(store.latestStyleId).toBe("style-a");
|
||||||
|
|
||||||
|
// A websocket message triggers onLocalStyleChange with a valid style.
|
||||||
|
expect(wsInstances.length).toBe(1);
|
||||||
|
wsInstances[0].onmessage!({ data: JSON.stringify({ version: 8, sources: {}, layers: [] }) });
|
||||||
|
expect(onLocalStyleChange).toHaveBeenCalledOnce();
|
||||||
|
expect(onLocalStyleChange.mock.calls[0][0].id).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("init throws a friendly error when the API is unreachable", async () => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("boom"); }));
|
||||||
|
const store = new ApiStyleStore({});
|
||||||
|
await expect(store.init()).rejects.toThrow("Can not connect to style API");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getLatestStyle fetches the latest style and validates it", async () => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn(async () => okJson(["s1"]) as any));
|
||||||
|
const store = new ApiStyleStore({});
|
||||||
|
await store.init();
|
||||||
|
|
||||||
|
vi.stubGlobal("fetch", vi.fn(async () => okJson({ version: 8, id: "s1", sources: {}, layers: [] }) as any));
|
||||||
|
const latest = await store.getLatestStyle();
|
||||||
|
expect(latest.id).toBe("s1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getLatestStyle throws when not initialised", async () => {
|
||||||
|
const store = new ApiStyleStore({});
|
||||||
|
await expect(store.getLatestStyle()).rejects.toThrow(/init the api backend/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("save PUTs the (token-stripped) style to the API", () => {
|
||||||
|
const fetchMock = vi.fn(async () => okJson({}) as any);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
const store = new ApiStyleStore({});
|
||||||
|
const mapStyle = { version: 8, id: "s1", sources: {}, layers: [] } as any;
|
||||||
|
|
||||||
|
expect(store.save(mapStyle)).toBe(mapStyle);
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"http://localhost:8000/styles/s1",
|
||||||
|
expect.objectContaining({ method: "PUT", mode: "cors" })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import style from "../style";
|
import {emptyStyle, ensureStyleValidity, replaceAccessTokens, stripAccessTokens} from "../style";
|
||||||
import {format} from "@maplibre/maplibre-gl-style-spec";
|
import {format} from "@maplibre/maplibre-gl-style-spec";
|
||||||
import ReconnectingWebSocket from "reconnecting-websocket";
|
import ReconnectingWebSocket from "reconnecting-websocket";
|
||||||
import type {IStyleStore, OnStyleChangedCallback, StyleSpecificationWithId} from "../definitions";
|
import type {IStyleStore, OnStyleChangedCallback, StyleSpecificationWithId} from "../definitions";
|
||||||
@@ -40,13 +40,13 @@ export class ApiStyleStore implements IStyleStore {
|
|||||||
connection.onmessage = e => {
|
connection.onmessage = e => {
|
||||||
if(!e.data) return;
|
if(!e.data) return;
|
||||||
console.log("Received style update from API");
|
console.log("Received style update from API");
|
||||||
let parsedStyle = style.emptyStyle;
|
let parsedStyle = emptyStyle;
|
||||||
try {
|
try {
|
||||||
parsedStyle = JSON.parse(e.data);
|
parsedStyle = JSON.parse(e.data);
|
||||||
} catch(err) {
|
} catch(err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
}
|
}
|
||||||
const updatedStyle = style.ensureStyleValidity(parsedStyle);
|
const updatedStyle = ensureStyleValidity(parsedStyle);
|
||||||
this.onLocalStyleChange(updatedStyle);
|
this.onLocalStyleChange(updatedStyle);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -57,7 +57,7 @@ export class ApiStyleStore implements IStyleStore {
|
|||||||
mode: "cors",
|
mode: "cors",
|
||||||
});
|
});
|
||||||
const body = await response.json();
|
const body = await response.json();
|
||||||
return style.ensureStyleValidity(body);
|
return ensureStyleValidity(body);
|
||||||
} else {
|
} else {
|
||||||
throw new Error("No latest style available. You need to init the api backend first.");
|
throw new Error("No latest style available. You need to init the api backend first.");
|
||||||
}
|
}
|
||||||
@@ -66,8 +66,8 @@ export class ApiStyleStore implements IStyleStore {
|
|||||||
// Save current style replacing previous version
|
// Save current style replacing previous version
|
||||||
save(mapStyle: StyleSpecificationWithId) {
|
save(mapStyle: StyleSpecificationWithId) {
|
||||||
const styleJSON = format(
|
const styleJSON = format(
|
||||||
style.stripAccessTokens(
|
stripAccessTokens(
|
||||||
style.replaceAccessTokens(mapStyle)
|
replaceAccessTokens(mapStyle)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { StyleStore } from "./stylestore";
|
||||||
|
|
||||||
|
class LocalStorageMock {
|
||||||
|
private store: Record<string, string> = {};
|
||||||
|
get length() {
|
||||||
|
return Object.keys(this.store).length;
|
||||||
|
}
|
||||||
|
key(i: number) {
|
||||||
|
return Object.keys(this.store)[i] ?? null;
|
||||||
|
}
|
||||||
|
getItem(k: string) {
|
||||||
|
return k in this.store ? this.store[k] : null;
|
||||||
|
}
|
||||||
|
setItem(k: string, v: string) {
|
||||||
|
this.store[k] = v;
|
||||||
|
}
|
||||||
|
removeItem(k: string) {
|
||||||
|
delete this.store[k];
|
||||||
|
}
|
||||||
|
clear() {
|
||||||
|
this.store = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vi.stubGlobal("window", { localStorage: new LocalStorageMock() });
|
||||||
|
// loadDefaultStyle fetches the default style over the network; serve it locally.
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async () => ({
|
||||||
|
json: async () => ({ version: 8, id: "default", sources: {}, layers: [] }),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const style = (id: string) => ({ version: 8, id, sources: {}, layers: [] }) as any;
|
||||||
|
|
||||||
|
describe("StyleStore", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
window.localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the default style when nothing is stored", async () => {
|
||||||
|
const store = new StyleStore();
|
||||||
|
const latest = await store.getLatestStyle();
|
||||||
|
expect(latest.id).toBe("default");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves a style and reads it back as the latest", async () => {
|
||||||
|
const store = new StyleStore();
|
||||||
|
store.save(style("abc"));
|
||||||
|
// A fresh store discovers the persisted style ids.
|
||||||
|
const reopened = new StyleStore();
|
||||||
|
const latest = await reopened.getLatestStyle();
|
||||||
|
expect(latest.id).toBe("abc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("purge removes all maputnik keys", async () => {
|
||||||
|
const store = new StyleStore();
|
||||||
|
store.save(style("abc"));
|
||||||
|
window.localStorage.setItem("unrelated", "keep");
|
||||||
|
store.purge();
|
||||||
|
expect(window.localStorage.getItem("maputnik:style:abc")).toBeNull();
|
||||||
|
expect(window.localStorage.getItem("unrelated")).toBe("keep");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import style from "../style";
|
import {ensureStyleValidity} from "../style";
|
||||||
import {loadStyleUrl} from "../urlopen";
|
import {loadStyleUrl} from "../urlopen";
|
||||||
import publicSources from "../../config/styles.json";
|
import publicSources from "../../config/styles.json";
|
||||||
import type {IStyleStore, StyleSpecificationWithId} from "../definitions";
|
import type {IStyleStore, StyleSpecificationWithId} from "../definitions";
|
||||||
@@ -89,7 +89,7 @@ export class StyleStore implements IStyleStore {
|
|||||||
|
|
||||||
// Save current style replacing previous version
|
// Save current style replacing previous version
|
||||||
save(mapStyle: StyleSpecificationWithId) {
|
save(mapStyle: StyleSpecificationWithId) {
|
||||||
mapStyle = style.ensureStyleValidity(mapStyle);
|
mapStyle = ensureStyleValidity(mapStyle);
|
||||||
const key = styleKey(mapStyle.id);
|
const key = styleKey(mapStyle.id);
|
||||||
|
|
||||||
const saveFn = () => {
|
const saveFn = () => {
|
||||||
|
|||||||
+1
-11
@@ -45,15 +45,6 @@ function ensureStyleValidity(style: StyleSpecification): StyleSpecificationWithI
|
|||||||
return ensureHasNoInteractive(ensureHasNoRefs(ensureHasId(style)));
|
return ensureHasNoInteractive(ensureHasNoRefs(ensureHasId(style)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function indexOfLayer(layers: LayerSpecification[], layerId: string) {
|
|
||||||
for (let i = 0; i < layers.length; i++) {
|
|
||||||
if(layers[i].id === layerId) {
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAccessToken(sourceName: string, mapStyle: StyleSpecification, opts: {allowFallback?: boolean}) {
|
function getAccessToken(sourceName: string, mapStyle: StyleSpecification, opts: {allowFallback?: boolean}) {
|
||||||
const metadata = mapStyle.metadata || {} as any;
|
const metadata = mapStyle.metadata || {} as any;
|
||||||
let accessToken = metadata[`maputnik:${sourceName}_access_token`];
|
let accessToken = metadata[`maputnik:${sourceName}_access_token`];
|
||||||
@@ -148,10 +139,9 @@ function stripAccessTokens(mapStyle: StyleSpecification) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export {
|
||||||
ensureStyleValidity,
|
ensureStyleValidity,
|
||||||
emptyStyle,
|
emptyStyle,
|
||||||
indexOfLayer,
|
|
||||||
generateId,
|
generateId,
|
||||||
getAccessToken,
|
getAccessToken,
|
||||||
replaceAccessTokens,
|
replaceAccessTokens,
|
||||||
|
|||||||
+3
-3
@@ -1,4 +1,4 @@
|
|||||||
import style from "./style";
|
import { emptyStyle, ensureStyleValidity } from "./style";
|
||||||
import { type StyleSpecificationWithId } from "./definitions";
|
import { type StyleSpecificationWithId } from "./definitions";
|
||||||
|
|
||||||
export function getStyleUrlFromAddressbarAndRemoveItIfNeeded(): string | null {
|
export function getStyleUrlFromAddressbarAndRemoveItIfNeeded(): string | null {
|
||||||
@@ -19,10 +19,10 @@ export async function loadStyleUrl(styleUrl: string): Promise<StyleSpecification
|
|||||||
credentials: "same-origin"
|
credentials: "same-origin"
|
||||||
});
|
});
|
||||||
const body = await response.json();
|
const body = await response.json();
|
||||||
return style.ensureStyleValidity(body);
|
return ensureStyleValidity(body);
|
||||||
} catch {
|
} catch {
|
||||||
console.warn("Could not fetch default style: " + styleUrl);
|
console.warn("Could not fetch default style: " + styleUrl);
|
||||||
return style.emptyStyle;
|
return emptyStyle;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-10
@@ -3,7 +3,7 @@
|
|||||||
"target": "ES2020",
|
"target": "ES2020",
|
||||||
"useDefineForClassFields": true,
|
"useDefineForClassFields": true,
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
"types": ["geojson", "@types/wicg-file-system-access"],
|
"types": ["geojson", "@types/wicg-file-system-access", "node"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|
||||||
@@ -21,14 +21,7 @@
|
|||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true
|
||||||
},
|
},
|
||||||
"include": ["src", "cypress/e2e"],
|
"include": ["src", "e2e"],
|
||||||
"exclude": ["dist"],
|
"exclude": ["dist"],
|
||||||
"references": [{ "path": "./tsconfig.node.json" }],
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
// TODO: Remove when issue is resolved https://github.com/cypress-io/cypress/issues/27448
|
|
||||||
"ts-node": {
|
|
||||||
"compilerOptions": {
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "Node",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-11
@@ -1,4 +1,3 @@
|
|||||||
import replace from "@rollup/plugin-replace";
|
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import istanbul from "vite-plugin-istanbul";
|
import istanbul from "vite-plugin-istanbul";
|
||||||
@@ -11,20 +10,11 @@ export default defineConfig(({ mode }) => ({
|
|||||||
sourcemap: true
|
sourcemap: true
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
replace({
|
|
||||||
preventAssignment: true,
|
|
||||||
include: /\/jsonlint-lines-primitives\/lib\/jsonlint.js/,
|
|
||||||
delimiters: ["", ""],
|
|
||||||
values: {
|
|
||||||
"_token_stack:": "",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
react(),
|
react(),
|
||||||
istanbul({
|
istanbul({
|
||||||
cypress: true,
|
|
||||||
requireEnv: false,
|
requireEnv: false,
|
||||||
nycrcPath: "./.nycrc.json",
|
nycrcPath: "./.nycrc.json",
|
||||||
forceBuildInstrument: true, //Instrument the source code for cypress runs
|
forceBuildInstrument: true, // Instrument the source so e2e runs can collect coverage
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
base: mode === "desktop" ? "/" : "/maputnik/",
|
base: mode === "desktop" ? "/" : "/maputnik/",
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ["src/**/*.test.{ts,tsx}"],
|
||||||
|
coverage: {
|
||||||
|
provider: "v8",
|
||||||
|
reporter: ["json", "lcov", "text-summary"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user