From be11f75c9f6ea6db7ab95236a694b8f644fd1601 Mon Sep 17 00:00:00 2001 From: HarelM Date: Sun, 12 Jul 2026 10:23:17 +0300 Subject: [PATCH] Add more end to end tests to increase coverage --- e2e/layer-editor.spec.ts | 146 +++++++++++++++++++++++ e2e/maputnik-driver.ts | 95 ++++++++++++--- e2e/modal-driver.ts | 40 +++++++ e2e/modals.spec.ts | 148 +++++++++++++++++++++++- src/components/FilterEditor.tsx | 2 + src/components/_DataProperty.tsx | 5 + src/components/_ExpressionProperty.tsx | 2 + src/components/_ZoomProperty.tsx | 3 + src/components/modals/ModalSettings.tsx | 7 ++ 9 files changed, 428 insertions(+), 20 deletions(-) diff --git a/e2e/layer-editor.spec.ts b/e2e/layer-editor.spec.ts index 64908116..716be4c8 100644 --- a/e2e/layer-editor.spec.ts +++ b/e2e/layer-editor.spec.ts @@ -212,6 +212,39 @@ describe("layer editor", () => { layers: [{ id, type: "fill", source: "example", filter: ["all", ["!=", "name", ""], ["==", "name", ""]] }], }); }); + + test("change the combining operator and delete a filter item", async () => { + const id = await when.modal.fillLayers({ type: "fill", layer: "example" }); + await when.addFilter(); + await when.addFilter(); + + await when.selectFilterCombiningOperator("any"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id, filter: ["any", ["==", "name", ""], ["==", "name", ""]] }], + }); + + await when.deleteFilterItem(); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id, filter: ["any", ["==", "name", ""]] }], + }); + }); + + test("convert a filter to an expression and delete it", async () => { + const id = await when.modal.fillLayers({ type: "fill", layer: "example" }); + await when.addFilter(); + + // A single-item "all" collapses to the bare comparison when migrated. + await when.convertFilterToExpression(); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id, filter: ["==", ["get", "name"], ""] }], + }); + + // Deleting the expression restores the default (empty) filter. + await when.deleteFilterExpression(); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id, filter: ["all"] }], + }); + }); }); describe("functions", () => { @@ -292,6 +325,119 @@ describe("layer editor", () => { ], }); }); + + test("edit the base and the stops of a zoom function", async () => { + await when.modal.fillLayers({ type: "circle", layer: "example" }); + await when.makeZoomFunction("circle-radius"); + + await when.setFunctionBase("circle-radius", "2"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ paint: { "circle-radius": { base: 2, stops: [[6, 5], [10, 5]] } } }], + }); + + // Editing a stop's zoom re-sorts the stops by zoom. + await when.setFunctionStopValue("circle-radius", "Zoom", 0, "3"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ paint: { "circle-radius": { base: 2, stops: [[3, 5], [10, 5]] } } }], + }); + + await when.setFunctionStopValue("circle-radius", "Output value", 0, "9"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ paint: { "circle-radius": { base: 2, stops: [[3, 9], [10, 5]] } } }], + }); + }); + + test("convert a zoom function to a data function and back", async () => { + await when.modal.fillLayers({ type: "circle", layer: "example" }); + await when.makeZoomFunction("circle-radius"); + + // Picking a non-interpolate scale turns the zoom function into a data one, + // carrying the existing stops over as zoom/value pairs. + await when.selectFunctionType("circle-radius", "categorical"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [ + { + paint: { + "circle-radius": { + property: "", + type: "exponential", + stops: [[{ zoom: 6, value: 0 }, 5], [{ zoom: 10, value: 0 }, 5]], + }, + }, + }, + ], + }); + + // Picking "interpolate" converts it back to a plain zoom function. + await when.selectFunctionType("circle-radius", "interpolate"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ paint: { "circle-radius": { stops: [[6, 5], [10, 5]] } } }], + }); + }); + + test("edit the property, default and stops of a data function", async () => { + await when.modal.fillLayers({ type: "circle", layer: "example" }); + await when.setValue("spec-field-input:circle-blur", "1"); + await when.makeDataFunction("circle-blur"); + + await when.setFunctionProperty("circle-blur", "myprop"); + await when.setFunctionDefault("circle-blur", "0.5"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ paint: { "circle-blur": { property: "myprop", default: 0.5 } } }], + }); + + await when.setFunctionStopValue("circle-blur", "Input value", 0, "7"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [ + { + paint: { + "circle-blur": { + property: "myprop", + stops: [[{ zoom: 6, value: 7 }, 1], [{ zoom: 10, value: 0 }, 1]], + }, + }, + }, + ], + }); + + await when.selectFunctionType("circle-blur", "categorical"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ paint: { "circle-blur": { property: "myprop", type: "categorical" } } }], + }); + }); + + test("convert a property to an expression, revert it and delete it", async () => { + await when.modal.fillLayers({ type: "circle", layer: "example" }); + await when.setValue("spec-field-input:circle-blur", "1"); + + await when.makeExpression("circle-blur"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ paint: { "circle-blur": ["literal", 1] } }], + }); + + // Reverting an expression restores the plain value it wrapped. + await when.undoExpression("circle-blur"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ paint: { "circle-blur": 1 } }], + }); + + // Deleting an expression falls back to the property's spec default. + await when.makeExpression("circle-blur"); + await when.deleteExpression("circle-blur"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ paint: { "circle-blur": 0 } }], + }); + }); + + test("convert a zoom function to an expression", async () => { + await when.modal.fillLayers({ type: "circle", layer: "example" }); + await when.makeZoomFunction("circle-radius"); + + await when.makeExpression("circle-radius"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ paint: { "circle-radius": ["interpolate", ["linear"], ["zoom"], 6, 5, 10, 5] } }], + }); + }); }); describe("layout", () => { diff --git a/e2e/maputnik-driver.ts b/e2e/maputnik-driver.ts index 3e1ebe6a..b82ff9f7 100644 --- a/e2e/maputnik-driver.ts +++ b/e2e/maputnik-driver.ts @@ -133,11 +133,6 @@ export class MaputnikDriver { await this.helper.when.click("layer-editor-group:" + title); }, - /** 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(); - }, - /** * Picks a source for the selected layer from the source autocomplete. * The autocomplete is a controlled (downshift) input, so the value has to be @@ -201,6 +196,62 @@ export class MaputnikDriver { await container.locator(".maputnik-delete-stop").first().click({ force: true }); }, + /** Turns the property into a raw style expression. */ + makeExpression: async (fieldName: string) => { + const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName); + await container.scrollIntoViewIfNeeded(); + // In the plain spec field the expression button shares the zoom-function + // class and comes first; inside a function editor it has its own test id. + const inFunctionEditor = container.locator("[data-wd-key='convert-to-expression']"); + const button = + (await inFunctionEditor.count()) > 0 + ? inFunctionEditor + : container.locator(".maputnik-make-zoom-function").first(); + await button.click({ force: true }); + }, + + /** Reverts an expression back to a plain value. */ + undoExpression: async (fieldName: string) => { + const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName); + await container.locator("[data-wd-key='undo-expression']").click({ force: true }); + }, + + /** Removes an expression, restoring the property's spec default. */ + deleteExpression: async (fieldName: string) => { + const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName); + await container.locator("[data-wd-key='delete-expression']").click({ force: true }); + }, + + /** Picks the function scale (categorical/interval/exponential/identity/interpolate). */ + selectFunctionType: async (fieldName: string, type: string) => { + const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName); + await container.locator("[data-wd-key='function-type'] select").selectOption(type); + }, + + /** Sets the "Base" input of a zoom/data function. */ + setFunctionBase: async (fieldName: string, value: string) => { + const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName); + await container.locator("[data-wd-key='function-base'] input").fill(value); + }, + + /** Sets the data property a data function keys off of. */ + setFunctionProperty: async (fieldName: string, value: string) => { + const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName); + await container.locator("[data-wd-key='function-property'] input").fill(value); + }, + + /** 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(); @@ -211,8 +262,29 @@ export class MaputnikDriver { await this.helper.get.element(".maputnik-filter-editor-operator select").first().selectOption(value); }, - deleteFirstActiveSource: async () => { - await this.helper.get.element(".maputnik-active-source-type-editor-header-delete").first().click(); + /** Chooses how the filter items combine: all / none / any. */ + selectFilterCombiningOperator: async (value: string) => { + await this.helper.when.selectWithin("filter-combining-operator", value); + }, + + deleteFilterItem: async (index = 0) => { + await this.helper.get + .element(".maputnik-filter-editor-block-action .maputnik-icon-button") + .nth(index) + .click(); + }, + + /** Converts the simple filter editor into a raw expression editor. */ + convertFilterToExpression: async () => { + await this.helper.when.click("filter-convert-to-expression"); + }, + + /** + * Deletes the filter expression, restoring the simple filter editor. + * The filter group precedes the paint group, so its button comes first. + */ + deleteFilterExpression: async () => { + await this.helper.get.element("[data-wd-key='delete-expression']").first().click(); }, setColorValue: async (fieldName: string, value: string) => { @@ -238,15 +310,6 @@ export class MaputnikDriver { await this.helper.when.typeText(text); }, - 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(); - }, - waitForExampleFileResponse: () => this.helper.when.waitForResponse("example-style.json"), /** Fill localStorage until we get a QuotaExceededError. */ diff --git a/e2e/modal-driver.ts b/e2e/modal-driver.ts index 3f7e4915..015ce9e5 100644 --- a/e2e/modal-driver.ts +++ b/e2e/modal-driver.ts @@ -34,5 +34,45 @@ export class ModalDriver { close: async (key: string) => { await this.helper.when.click(key + ".close-modal"); }, + + /** + * Adds a source of the given type from the sources modal, keeping whatever + * defaults that type's editor prefills. + */ + addSource: async (sourceId: string, sourceType: string) => { + const { when } = this.helper; + await when.setValue("modal:sources.add.source_id", sourceId); + await when.select("modal:sources.add.source_type", sourceType); + await when.click("modal:sources.add.add_source"); + await when.wait(200); + }, + + /** Adds one of the predefined public sources listed in the sources modal. */ + addPublicSource: async (index = 0) => { + await this.helper.get.element(".maputnik-public-source-select").nth(index).click(); + }, + + deleteFirstActiveSource: async () => { + await this.helper.get.element(".maputnik-active-source-type-editor-header-delete").first().click(); + }, + + /** Fills one number box of a coordinate pair in the image/video source editor. */ + setCoordinateValue: async (index: number, value: string) => { + const input = this.helper.get + .elementByTestId("modal:sources") + .locator(".maputnik-array input") + .nth(index); + await input.fill(value); + await input.blur(); + }, + + exportCreateHtml: async () => { + await this.helper.get.element(".maputnik-modal-export-buttons button").last().click(); + }, + + exportSaveStyle: async () => { + await this.helper.stubSaveFilePicker(); + await this.helper.get.element(".maputnik-modal-export-buttons button").first().click(); + }, }; } diff --git a/e2e/modals.spec.ts b/e2e/modals.spec.ts index 0bdbc77a..bb2477c6 100644 --- a/e2e/modals.spec.ts +++ b/e2e/modals.spec.ts @@ -66,11 +66,11 @@ describe("modals", () => { test("download HTML and save the style", async () => { // Generate the standalone HTML export (triggers a file download). - await when.exportCreateHtml(); + await when.modal.exportCreateHtml(); await then(get.elementByTestId("modal:export")).shouldExist(); // Saving the style closes the export modal. - await when.exportSaveStyle(); + await when.modal.exportSaveStyle(); await then(get.elementByTestId("modal:export")).shouldNotExist(); }); }); @@ -85,14 +85,14 @@ describe("modals", () => { await when.setStyle("both"); await when.click("nav:sources"); const before = Object.keys(get.fixture("geojson-raster-style.json").sources).length; - await when.deleteFirstActiveSource(); + await when.modal.deleteFirstActiveSource(); await then( get.styleFromLocalStorage().then((style) => Object.keys(style.sources).length) ).shouldEqual(before - 1); }); test("public source", async () => { - await when.addPublicSource(); + await when.modal.addPublicSource(); await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ sources: { openmaptiles: { @@ -148,6 +148,108 @@ describe("modals", () => { sources: { [sourceId]: { tileSize: 128 } }, }); }); + + test("add new geojson url source", async () => { + await when.modal.addSource("geojsonurl", "geojson_url"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + sources: { + geojsonurl: { type: "geojson", data: "http://localhost:3000/geojson.json" }, + }, + }); + }); + + test("add new geojson json source", async () => { + await when.modal.addSource("geojsonjson", "geojson_json"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + sources: { + geojsonjson: { type: "geojson", cluster: false, data: "" }, + }, + }); + }); + + test("add new tilejson vector source", async () => { + await when.modal.addSource("tilejsonvector", "tilejson_vector"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + sources: { + tilejsonvector: { type: "vector", url: "http://localhost:3000/tilejson.json" }, + }, + }); + }); + + test("add new tilejson raster source", async () => { + await when.modal.addSource("tilejsonraster", "tilejson_raster"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + sources: { + tilejsonraster: { type: "raster", url: "http://localhost:3000/tilejson.json" }, + }, + }); + }); + + test("add new tilejson raster-dem source", async () => { + await when.modal.addSource("tilejsonrasterdem", "tilejson_raster-dem"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + sources: { + tilejsonrasterdem: { type: "raster-dem", url: "http://localhost:3000/tilejson.json" }, + }, + }); + }); + + test("add new tile xyz raster-dem source", async () => { + await when.modal.addSource("tilexyzrasterdem", "tilexyz_raster-dem"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + sources: { + tilexyzrasterdem: { + type: "raster-dem", + tiles: ["http://localhost:3000/{x}/{y}/{z}.png"], + minzoom: 0, + maxzoom: 14, + tileSize: 512, + }, + }, + }); + }); + + test("add new image source", async () => { + await when.modal.addSource("imagesource", "image"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + sources: { + imagesource: { + type: "image", + url: "http://localhost:3000/image.png", + coordinates: [[0, 0], [0, 0], [0, 0], [0, 0]], + }, + }, + }); + }); + + test("add new video source", async () => { + await when.modal.addSource("videosource", "video"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + sources: { + videosource: { + type: "video", + urls: ["http://localhost:3000/movie.mp4"], + coordinates: [[0, 0], [0, 0], [0, 0], [0, 0]], + }, + }, + }); + }); + + test("edit the corner coordinates of an image source", async () => { + const sourceId = "imagecoords"; + await when.setValue("modal:sources.add.source_id", sourceId); + await when.select("modal:sources.add.source_type", "image"); + // The first corner is the first two number boxes of the coordinate arrays. + await when.modal.setCoordinateValue(0, "1"); + await when.modal.setCoordinateValue(1, "2"); + await when.click("modal:sources.add.add_source"); + await when.wait(200); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + sources: { + [sourceId]: { type: "image", coordinates: [[1, 2], [0, 0], [0, 0], [0, 0]] }, + }, + }); + }); }); describe("inspect", () => { @@ -262,6 +364,44 @@ describe("modals", () => { }); }); + test("map view defaults", async () => { + await when.setValue("modal:settings.zoom", "4"); + await when.setValue("modal:settings.bearing", "12"); + await when.setValue("modal:settings.pitch", "30"); + await when.click("modal:settings.name"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + zoom: 4, + bearing: 12, + pitch: 30, + }); + }); + + test("light intensity", async () => { + await when.setValue("modal:settings.light-intensity", "0.7"); + await when.click("modal:settings.name"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + light: { intensity: 0.7 }, + }); + }); + + test("terrain source and exaggeration", async () => { + await when.setValue("modal:settings.maputnik:terrain_source", "terrain"); + await when.setValue("modal:settings.terrain-exaggeration", "1.5"); + await when.click("modal:settings.name"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + terrain: { source: "terrain", exaggeration: 1.5 }, + }); + }); + + test("transition delay and duration", async () => { + await when.setValue("modal:settings.transition-delay", "100"); + await when.setValue("modal:settings.transition-duration", "500"); + await when.click("modal:settings.name"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + transition: { delay: 100, duration: 500 }, + }); + }); + test("style projection mercator", async () => { await when.select("modal:settings.projection", "mercator"); await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ diff --git a/src/components/FilterEditor.tsx b/src/components/FilterEditor.tsx index 83478b7a..c789cb64 100644 --- a/src/components/FilterEditor.tsx +++ b/src/components/FilterEditor.tsx @@ -215,6 +215,7 @@ class FilterEditorInternal extends React.Component @@ -248,6 +249,7 @@ class FilterEditorInternal extends React.Component
diff --git a/src/components/_ExpressionProperty.tsx b/src/components/_ExpressionProperty.tsx index e7a6db73..089b3e2b 100644 --- a/src/components/_ExpressionProperty.tsx +++ b/src/components/_ExpressionProperty.tsx @@ -50,6 +50,7 @@ class ExpressionPropertyInternal extends React.Component @@ -59,6 +60,7 @@ class ExpressionPropertyInternal extends React.Component diff --git a/src/components/_ZoomProperty.tsx b/src/components/_ZoomProperty.tsx index cc91d8e9..463d34b5 100644 --- a/src/components/_ZoomProperty.tsx +++ b/src/components/_ZoomProperty.tsx @@ -194,6 +194,7 @@ class ZoomPropertyInternal extends React.Component
diff --git a/src/components/modals/ModalSettings.tsx b/src/components/modals/ModalSettings.tsx index cde26567..5c663928 100644 --- a/src/components/modals/ModalSettings.tsx +++ b/src/components/modals/ModalSettings.tsx @@ -206,6 +206,7 @@ class ModalSettingsInternal extends React.Component