import { expect, test, type Page } from "@playwright/test"; import { mockWorkbenchContext } from "./support/mock-workbench-context"; import { mockPipeVectorTiles } from "./support/mock-pipe-vector-tiles"; declare global { interface Window { __layerFadeSamples: number[]; __layerFadePixels: number[]; } } async function openLayerFixture(page: Page, vectorPipes = false) { await mockWorkbenchContext(page); if (vectorPipes) await mockPipeVectorTiles(page); await page.route("https://workbench-map.invalid/geoserver/tjwater_next/ows?**", (route) => { const type = new URL(route.request().url()).searchParams.get("typeNames"); return route.fulfill({ contentType: "application/xml", body: `` }); }); await page.goto("/", { waitUntil: "domcontentloaded" }); await expect.poll(() => page.evaluate(() => Boolean( window.__waterNetworkMap?.getLayer("reservoirs-symbol") )), { timeout: 15_000 }).toBe(true); await page.evaluate((vectorPipes) => { const map = window.__waterNetworkMap!; for (const sourceId of ["pipes", "reservoirs", "water_quality_monitoring_points"]) { if (vectorPipes && sourceId === "pipes") continue; const layers = map.getStyle().layers.filter((layer) => "source" in layer && layer.source === sourceId); [...layers].reverse().forEach((layer) => map.removeLayer(layer.id)); map.removeSource(sourceId); map.addSource(sourceId, { type: "geojson", data: { type: "FeatureCollection", features: [{ type: "Feature", properties: { id: "fade-fixture", diameter: 800 }, geometry: sourceId === "pipes" ? { type: "LineString", coordinates: [[121.45, 30.91], [121.48, 30.91]] } : { type: "Point", coordinates: [sourceId === "reservoirs" ? 121.46 : 121.47, 30.915] } }] } }); layers.forEach((layer) => { const copy = { ...layer }; if ("source-layer" in copy) delete copy["source-layer"]; map.addLayer(copy); }); } const scada = map.getSource("scada"); if (scada && "setData" in scada) scada.setData({ type: "FeatureCollection", features: [{ type: "Feature", id: "pressure-size-fixture", properties: { device_id: "pressure-size-fixture", device_type: "pressure" }, geometry: { type: "Point", coordinates: [121.475, 30.915] } }] }); map.jumpTo({ center: [121.465, 30.913], zoom: 13, pitch: 0, bearing: 0 }); }, vectorPipes); await page.getByRole("button", { name: /图层:管理地图图层/ }).click(); } for (const [label, layerId, property] of [ ["管线", "pipes-flow", "line-opacity"], ["水库", "reservoirs-symbol", "icon-opacity"], ["水质监测点", "water_quality_monitoring_points-symbol", "icon-opacity"] ] as const) { test(`${label} fades on toggle and restores its original paint`, async ({ page }, testInfo) => { await openLayerFixture(page); const original = await page.evaluate(({ layerId, property }) => { const map = window.__waterNetworkMap!; const original = map.getPaintProperty(layerId, property); const setPaint = map.setPaintProperty.bind(map); window.__layerFadeSamples = []; map.setPaintProperty = (id, name, value, options) => { if (id === layerId && name === property) { if (typeof value === "number" && typeof original === "number") { window.__layerFadeSamples.push(value / original); } const output = Array.isArray(value) && value[0] === "interpolate" ? value[4] : value; if (Array.isArray(output) && output[0] === "*") { window.__layerFadeSamples.push(Number(output.at(-1))); } } return setPaint(id, name, value, options); }; return original; }, { layerId, property }); const toggle = page.getByRole("button", { name: new RegExp(`^${label},`) }); await toggle.click(); await expect.poll(() => page.evaluate((id) => window.__waterNetworkMap! .getLayoutProperty(id, "visibility"), layerId)).toBe("none"); const hiddenSamples = await page.evaluate(() => window.__layerFadeSamples); expect(hiddenSamples.some((value) => value > 0 && value < 1)).toBe(true); expect(await page.evaluate(({ layerId, property }) => window.__waterNetworkMap! .getPaintProperty(layerId, property), { layerId, property })).toEqual(original); await toggle.click(); await expect.poll(() => page.evaluate(({ layerId, property }) => window.__waterNetworkMap! .getPaintProperty(layerId, property), { layerId, property })).toEqual(original); await page.screenshot({ path: testInfo.outputPath("layer-visible.png") }); await toggle.click(); await toggle.click(); await expect.poll(() => page.evaluate((id) => window.__waterNetworkMap! .getLayoutProperty(id, "visibility"), layerId)).toBe("visible"); await expect.poll(() => page.evaluate(({ layerId, property }) => window.__waterNetworkMap! .getPaintProperty(layerId, property), { layerId, property })).toEqual(original); }); } test("reduced motion switches layers without a fade", async ({ page }) => { await page.emulateMedia({ reducedMotion: "reduce" }); await openLayerFixture(page); await page.getByRole("button", { name: /^管线,/ }).click(); expect(await page.evaluate(() => window.__waterNetworkMap! .getLayoutProperty("pipes-flow", "visibility"))).toBe("none"); }); for (const [label, layerId, property, longitude, latitude] of [ ["管线", "pipes-flow", "line-opacity", 121.465, 30.91], ["水库", "reservoirs-symbol", "icon-opacity", 121.46, 30.915], ["水质监测点", "water_quality_monitoring_points-symbol", "icon-opacity", 121.47, 30.915] ] as const) { test(`${label} has intermediate rendered pixels while fading in and out`, async ({ page }, testInfo) => { await openLayerFixture(page, label === "管线"); await expect.poll(() => page.evaluate((id) => window.__waterNetworkMap! .queryRenderedFeatures({ layers: [id] }).length, layerId)).toBeGreaterThan(0); await page.evaluate(() => new Promise((resolve) => { const map = window.__waterNetworkMap!; map.once("idle", () => resolve()); map.triggerRepaint(); })); await page.screenshot({ path: testInfo.outputPath("fade-before.png") }); const original = await page.evaluate(({ layerId, property, longitude, latitude }) => { const map = window.__waterNetworkMap!; const canvas = map.getCanvas(); const gl = canvas.getContext("webgl2") ?? canvas.getContext("webgl"); if (!gl) throw new Error("No WebGL context"); window.__layerFadePixels = []; map.on("render", () => { const point = map.project([longitude, latitude]); const ratio = canvas.width / canvas.clientWidth; const pixels = new Uint8Array(7 * 7 * 4); gl.readPixels(Math.round(point.x * ratio) - 3, canvas.height - Math.round(point.y * ratio) - 3, 7, 7, gl.RGBA, gl.UNSIGNED_BYTE, pixels); let darkest = 0; for (let index = 0; index < pixels.length; index += 4) { darkest = Math.max(darkest, 765 - pixels[index] - pixels[index + 1] - pixels[index + 2]); } window.__layerFadePixels.push(darkest); }); map.triggerRepaint(); return map.getPaintProperty(layerId, property); }, { layerId, property, longitude, latitude }); const toggle = page.getByRole("button", { name: new RegExp(`^${label},`) }); await toggle.click(); await expect.poll(() => page.evaluate((id) => window.__waterNetworkMap! .getLayoutProperty(id, "visibility"), layerId)).toBe("none"); const hiding = await page.evaluate(() => window.__layerFadePixels); const min = Math.min(...hiding); const max = Math.max(...hiding); await testInfo.attach("hiding-pixels", { body: JSON.stringify(hiding), contentType: "application/json" }); expect(max - min).toBeGreaterThan(50); // Require multiple rendered intermediate alpha states without prescribing a GPU frame rate. expect(new Set(hiding.filter((pixel) => pixel > min + 10 && pixel < max - 10)).size).toBeGreaterThan(1); await page.evaluate(() => { window.__layerFadePixels = []; }); await toggle.click(); await expect.poll(() => page.evaluate(({ layerId, property }) => window.__waterNetworkMap! .getPaintProperty(layerId, property), { layerId, property })).toEqual(original); const showing = await page.evaluate(() => window.__layerFadePixels); expect(new Set(showing.filter((pixel) => pixel > min + 10 && pixel < max - 10)).size).toBeGreaterThan(1); await testInfo.attach("rendered-opacity", { body: JSON.stringify({ hiding, showing }), contentType: "application/json" }); }); }