Files
next-tjwater-frontend/tests/browser/layer-visibility-fade.e2e.ts
T
jiang 209ee8c2ea
Generic Container CI/CD / test-build-publish (push) Successful in 1m12s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m12s
fix(map): clamp layer fade progress to the valid range
A frame timestamp may precede performance.now sampled when hiding starts, producing negative progress and opacity above 1. Bound progress at both ends; cover the exact label failure and real MapLibre paint validation.
2026-11-12 13:49:36 +08:00

185 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: `<wfs:FeatureCollection xmlns:wfs="http://www.opengis.net/wfs/2.0" numberMatched="${type?.endsWith(":reservoirs") || type?.endsWith(":water_quality_monitoring_points") ? 1 : 0}"/>` });
});
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();
}
test("analysis label fade accepts a frame timestamp earlier than the visibility change", async ({ page }) => {
await openLayerFixture(page);
await page.evaluate(async () => {
const modulePath = "/src/features/workbench/map/layer-visibility.ts";
const { setMapLayersVisibility } = await import(modulePath);
const map = window.__waterNetworkMap!;
const errors: string[] = [];
map.on("error", (event) => {
if (event.error.message.includes("simulation-backend-analysis-label")) errors.push(event.error.message);
});
Object.assign(window, { analysisFadeErrors: errors });
const nativeFrame = window.requestAnimationFrame.bind(window);
window.requestAnimationFrame = (callback) => nativeFrame((time) => callback(time - 100));
try { setMapLayersVisibility(map, ["simulation-backend-analysis-label"], false); }
finally { window.requestAnimationFrame = nativeFrame; }
});
await expect.poll(() => page.evaluate(() => window.__waterNetworkMap!
.getLayoutProperty("simulation-backend-analysis-label", "visibility"))).toBe("none");
expect(await page.evaluate(() => (window as unknown as { analysisFadeErrors: string[] }).analysisFadeErrors)).toEqual([]);
});
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<void>((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"
});
});
}