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"
});
});
}