Unify dock surfaces, headers and dividers, and remove the visible context strip. Limit pointer resizing to 400-640px and reveal the edge grip on hover. Resize and redraw the map in the same layout cycle to prevent blanking and lag. Preserve horizontal centering during conversation control press feedback. Validation: isolated lint, typecheck, 21 focused unit tests, and 10 relevant browser checks passed. Snapshot baselines regenerated from the isolated change. The existing mobile alert summary test also fails on unchanged HEAD because the expected demo alert source is unavailable in this environment.
356 lines
14 KiB
TypeScript
356 lines
14 KiB
TypeScript
import { expect, test, type Page } from "@playwright/test";
|
|
import { mockAgentApi } from "./support/mock-agent-api";
|
|
import { mockScadaApi } from "./support/mock-scada-api";
|
|
|
|
const EMPTY_RASTER_TILE = Buffer.from(
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
|
"base64"
|
|
);
|
|
|
|
test.describe("Main Frame workbench", () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await page.clock.setFixedTime(new Date("2026-07-21T10:40:00+08:00"));
|
|
await page.emulateMedia({ reducedMotion: "reduce" });
|
|
await prepareDeterministicWorkbench(page);
|
|
});
|
|
|
|
test("desktop light basemap keeps the Main Frame workspace legible", async ({ page }) => {
|
|
await page.setViewportSize({ width: 1440, height: 900 });
|
|
await openWorkbench(page);
|
|
await openDesktopAgent(page);
|
|
|
|
await expectDesktopMainFrameGeometry(page);
|
|
await expectSolidMainFrameMaterials(page);
|
|
await expectLoadedChineseFont(page);
|
|
await expectBodyContrast(page);
|
|
await expect(page).toHaveScreenshot("workbench-desktop-light.png", {
|
|
animations: "disabled",
|
|
maxDiffPixelRatio: 0.01
|
|
});
|
|
});
|
|
|
|
test("desktop satellite basemap preserves the solid Main Frame hierarchy", async ({ page }) => {
|
|
await page.setViewportSize({ width: 1440, height: 900 });
|
|
await openWorkbench(page);
|
|
await openDesktopAgent(page);
|
|
await selectBasemap(page, "影像");
|
|
|
|
await expect(page.locator("main")).toHaveAttribute("data-basemap-tone", "satellite");
|
|
await expectDesktopMainFrameGeometry(page);
|
|
await expectSolidMainFrameMaterials(page);
|
|
await expect(page).toHaveScreenshot("workbench-desktop-satellite.png", {
|
|
animations: "disabled",
|
|
maxDiffPixelRatio: 0.01
|
|
});
|
|
});
|
|
|
|
test("desktop selector switches Tianditu vector and imagery layer pairs", async ({ page }) => {
|
|
await page.setViewportSize({ width: 1280, height: 800 });
|
|
await openWorkbench(page);
|
|
|
|
await selectBasemap(page, "天地图矢量");
|
|
await expectBaseLayerVisibility(page, {
|
|
"tianditu-vector": "visible",
|
|
"tianditu-vector-annotation": "visible",
|
|
"tianditu-image": "none",
|
|
"tianditu-image-annotation": "none"
|
|
});
|
|
|
|
await selectBasemap(page, "天地图影像");
|
|
await expectBaseLayerVisibility(page, {
|
|
"tianditu-vector": "none",
|
|
"tianditu-vector-annotation": "none",
|
|
"tianditu-image": "visible",
|
|
"tianditu-image-annotation": "visible"
|
|
});
|
|
});
|
|
|
|
test("map focus keeps pointer interaction borderless and uses the app focus style", async ({ page }) => {
|
|
await page.setViewportSize({ width: 1440, height: 900 });
|
|
await openWorkbench(page);
|
|
await openDesktopAgent(page);
|
|
const canvas = page.locator("#workspace-map .maplibregl-canvas");
|
|
const box = (await canvas.boundingBox())!;
|
|
await canvas.click({ position: { x: box.width / 2, y: box.height / 2 } });
|
|
await expect(canvas).toBeFocused();
|
|
await expect(canvas).toHaveCSS("outline-style", "none");
|
|
await expect(canvas).toHaveCSS("border-width", "0px");
|
|
await expect(page.locator("#main-workspace")).toHaveCSS("border-width", "0px");
|
|
await page.keyboard.press("Tab");
|
|
await canvas.focus();
|
|
expect(await canvas.evaluate((element) => element.matches(":focus-visible"))).toBe(true);
|
|
await expect(canvas).toHaveCSS("outline-style", "solid");
|
|
await expect(canvas).toHaveCSS("outline-width", "1px");
|
|
await expect(canvas).toHaveCSS("outline-color", "rgba(37, 99, 235, 0.34)");
|
|
await expect(canvas).toHaveCSS("outline-offset", "-1px");
|
|
await page.screenshot({ path: "test-results/agent-history-ui/map-pointer-focus.png" });
|
|
});
|
|
|
|
test("mobile layer panel keeps Tianditu choices usable", async ({ page }) => {
|
|
await page.setViewportSize({ width: 375, height: 812 });
|
|
await openWorkbench(page);
|
|
await selectBasemap(page, "天地图矢量");
|
|
|
|
await expect(page.locator("main")).toHaveAttribute("data-basemap-tone", "light");
|
|
});
|
|
|
|
test("wide desktop expands the map document when Agent is collapsed", async ({ page }) => {
|
|
await page.setViewportSize({ width: 1920, height: 1080 });
|
|
await openWorkbench(page);
|
|
await openDesktopAgent(page);
|
|
|
|
const agentPanel = page.getByRole("complementary", { name: "Agent 命令面板" });
|
|
const workspace = page.locator("#main-workspace");
|
|
const expandedWorkspace = await workspace.boundingBox();
|
|
expect(expandedWorkspace).not.toBeNull();
|
|
await expect(agentPanel).toHaveCSS("width", "540px");
|
|
|
|
await page.getByRole("button", { name: "折叠 Agent 面板" }).click();
|
|
await expect(page.getByLabel("Agent 折叠栏")).toBeVisible();
|
|
await expect.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0)).toBe(0);
|
|
await expect
|
|
.poll(async () => Math.round((await workspace.boundingBox())?.width ?? 0))
|
|
.toBe(1920);
|
|
expect((await workspace.boundingBox())!.width).toBeGreaterThan(expandedWorkspace!.width);
|
|
await expect(page).toHaveScreenshot("workbench-desktop-wide-max-agent.png", {
|
|
animations: "disabled",
|
|
maxDiffPixelRatio: 0.01
|
|
});
|
|
});
|
|
|
|
test("mobile launcher keeps Agent, map tools, and conditions visually balanced", async ({
|
|
page
|
|
}) => {
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
await openWorkbench(page);
|
|
|
|
await expect(page).toHaveScreenshot("workbench-mobile-launcher-light.png", {
|
|
animations: "disabled",
|
|
maxDiffPixelRatio: 0.01
|
|
});
|
|
});
|
|
|
|
test("mobile light basemap shows the Agent half sheet", async ({ page }) => {
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
await openWorkbench(page);
|
|
await page.getByRole("button", { name: "打开 Agent 面板" }).click();
|
|
|
|
const sheet = page.getByRole("region", { name: "Agent 工作台抽屉" });
|
|
await expect(sheet).toHaveAttribute("data-snap", "half");
|
|
await expect.poll(async () => Math.round((await sheet.boundingBox())?.height ?? 0)).toBe(439);
|
|
await expect(page).toHaveScreenshot("workbench-mobile-agent-light.png", {
|
|
animations: "disabled",
|
|
maxDiffPixelRatio: 0.01
|
|
});
|
|
});
|
|
|
|
test("mobile satellite basemap shows the condition half sheet", async ({ page }) => {
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
await openWorkbench(page);
|
|
await selectBasemap(page, "影像");
|
|
await page.getByRole("button", { name: "打开工况任务" }).click();
|
|
|
|
const sheet = page.getByRole("region", { name: "工况任务抽屉" });
|
|
await expect(sheet).toHaveAttribute("data-snap", "half");
|
|
await expect(page.locator("main")).toHaveAttribute("data-basemap-tone", "satellite");
|
|
await expect(page).toHaveScreenshot("workbench-mobile-condition-satellite.png", {
|
|
animations: "disabled",
|
|
maxDiffPixelRatio: 0.01
|
|
});
|
|
});
|
|
});
|
|
|
|
async function prepareDeterministicWorkbench(page: Page) {
|
|
await mockAgentApi(page, {
|
|
model: {
|
|
id: "deepseek/deepseek-v4-flash",
|
|
label: "快速",
|
|
description: "快速响应",
|
|
icon: "bolt"
|
|
}
|
|
});
|
|
await page.route("**/runtime-config.js", async (route) => {
|
|
await route.fulfill({
|
|
contentType: "application/javascript",
|
|
body: `globalThis.__TJWATER_CONFIG__ = {
|
|
TJWATER_AUTH_MODE: "disabled",
|
|
TJWATER_MAPBOX_ACCESS_TOKEN: "visual-test-token",
|
|
TJWATER_TIANDITU_TOKEN: "visual-test-tianditu-token",
|
|
TJWATER_MAP_URL: "https://visual-map.invalid/geoserver",
|
|
TJWATER_GEOSERVER_WORKSPACE: "tjwater_next",
|
|
TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid",
|
|
TJWATER_AGENT_API_BASE_URL: "http://127.0.0.1:8787",
|
|
TJWATER_ENABLE_DEV_PANEL: "false",
|
|
TJWATER_ENABLE_MSW: "false"
|
|
};`
|
|
});
|
|
});
|
|
await page.route("https://api.mapbox.com/**", async (route) => {
|
|
await route.fulfill({ contentType: "image/png", body: EMPTY_RASTER_TILE });
|
|
});
|
|
await page.route("https://t0.tianditu.gov.cn/**", async (route) => {
|
|
await route.fulfill({ contentType: "image/png", body: EMPTY_RASTER_TILE });
|
|
});
|
|
await page.route("https://visual-map.invalid/**", async (route) => {
|
|
await route.fulfill({
|
|
contentType: "application/vnd.mapbox-vector-tile",
|
|
body: Buffer.alloc(0)
|
|
});
|
|
});
|
|
await page.route("https://demotiles.maplibre.org/**", async (route) => {
|
|
await route.fulfill({ status: 204, body: "" });
|
|
});
|
|
await page.route("**/*.riv", async (route) => {
|
|
await route.abort();
|
|
});
|
|
await mockScadaApi(page);
|
|
}
|
|
|
|
async function openWorkbench(page: Page) {
|
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
|
const workbench = page.locator("main[data-basemap-tone]");
|
|
await workbench.waitFor();
|
|
await workbench.evaluate((element) => {
|
|
element.setAttribute("data-visual-test", "true");
|
|
});
|
|
await page.addStyleTag({
|
|
content: `
|
|
[data-sonner-toaster] { display: none !important; }
|
|
[data-visual-test] .maplibregl-canvas { opacity: 0.28; }
|
|
[data-visual-test][data-basemap-tone="light"] .map-grid {
|
|
background:
|
|
radial-gradient(circle at 70% 32%, rgba(56, 120, 170, 0.18) 0 3px, transparent 4px),
|
|
linear-gradient(118deg, transparent 0 46%, rgba(65, 121, 160, 0.12) 46.2% 46.7%, transparent 47%),
|
|
linear-gradient(90deg, rgba(75, 124, 160, 0.06) 1px, transparent 1px),
|
|
linear-gradient(rgba(75, 124, 160, 0.06) 1px, transparent 1px),
|
|
#dce8ef;
|
|
background-size: auto, auto, 48px 48px, 48px 48px, auto;
|
|
}
|
|
[data-visual-test][data-basemap-tone="satellite"] .map-grid {
|
|
background:
|
|
radial-gradient(circle at 68% 30%, rgba(183, 207, 159, 0.24) 0 4px, transparent 5px),
|
|
linear-gradient(126deg, transparent 0 44%, rgba(178, 195, 153, 0.2) 44.2% 44.8%, transparent 45%),
|
|
repeating-linear-gradient(24deg, rgba(34, 67, 58, 0.18) 0 1px, transparent 1px 22px),
|
|
#526961;
|
|
}
|
|
`
|
|
});
|
|
await page.evaluate(async () => {
|
|
await document.fonts.ready;
|
|
});
|
|
await page.waitForFunction(() =>
|
|
Boolean(window.__waterNetworkMap?.getLayer("scada-pressure-hit"))
|
|
);
|
|
}
|
|
|
|
type BasemapLabel = "浅色" | "影像" | "天地图矢量" | "天地图影像";
|
|
|
|
async function selectBasemap(page: Page, label: BasemapLabel) {
|
|
await page.getByRole("button", { name: /图层:管理地图图层/ }).click();
|
|
await page.getByRole("button", { name: label, exact: true }).click();
|
|
await page.getByRole("button", { name: /图层:管理地图图层/ }).click();
|
|
await expect(page.locator("main")).toHaveAttribute(
|
|
"data-basemap-tone",
|
|
["影像", "天地图影像"].includes(label) ? "satellite" : "light"
|
|
);
|
|
}
|
|
|
|
async function expectBaseLayerVisibility(page: Page, expected: Record<string, "visible" | "none">) {
|
|
await expect
|
|
.poll(async () =>
|
|
page.evaluate((layerIds) => {
|
|
const map = window.__waterNetworkMap;
|
|
return Object.fromEntries(
|
|
layerIds.map((layerId) => [
|
|
layerId,
|
|
map?.getLayoutProperty(layerId, "visibility") ?? "visible"
|
|
])
|
|
);
|
|
}, Object.keys(expected))
|
|
)
|
|
.toEqual(expected);
|
|
}
|
|
|
|
async function openDesktopAgent(page: Page) {
|
|
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
|
|
await expect(page.getByRole("complementary", { name: "Agent 命令面板" })).toBeVisible();
|
|
}
|
|
|
|
async function expectDesktopMainFrameGeometry(page: Page) {
|
|
const workspace = page.locator("#main-workspace");
|
|
const mapWindow = page.locator("#workspace-map");
|
|
const agentPanel = page.getByRole("complementary", { name: "Agent 命令面板" });
|
|
await expect(agentPanel).toHaveCSS("width", "500px");
|
|
await expect(agentPanel).toHaveCSS("border-radius", "0px");
|
|
await expect(agentPanel).toHaveCSS("border-right-width", "1px");
|
|
await expect(mapWindow).toHaveCSS("border-radius", "0px");
|
|
|
|
const workspaceBox = await workspace.boundingBox();
|
|
const mapBox = await mapWindow.boundingBox();
|
|
const agentBox = await agentPanel.boundingBox();
|
|
expect(workspaceBox).not.toBeNull();
|
|
expect(mapBox).not.toBeNull();
|
|
expect(agentBox).not.toBeNull();
|
|
expect(agentBox!.x).toBe(0);
|
|
expect(agentBox!.y).toBe(56);
|
|
expect(workspaceBox!.x).toBe(agentBox!.x + agentBox!.width);
|
|
expect(workspaceBox!.y).toBe(agentBox!.y);
|
|
expect(mapBox).toEqual(workspaceBox);
|
|
}
|
|
|
|
async function expectSolidMainFrameMaterials(page: Page) {
|
|
const materials = await page.evaluate(() => {
|
|
const agent = getComputedStyle(document.querySelector('aside[aria-label="Agent 命令面板"]')!);
|
|
return {
|
|
agentBackdrop: agent.backdropFilter,
|
|
agentBackground: agent.backgroundColor,
|
|
agentShadow: agent.boxShadow
|
|
};
|
|
});
|
|
expect(materials.agentBackdrop).toBe("none");
|
|
expect(materials.agentBackground).toBe("oklch(0.972 0.005 250)");
|
|
expect(materials.agentShadow).toBe("none");
|
|
}
|
|
|
|
async function expectLoadedChineseFont(page: Page) {
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(() => document.fonts.check('16px "Noto Sans SC Variable"', "供水管网"))
|
|
)
|
|
.toBe(true);
|
|
}
|
|
|
|
async function expectBodyContrast(page: Page) {
|
|
const contrast = await page
|
|
.getByText("直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。")
|
|
.evaluate((element) => {
|
|
const foreground = parseColor(getComputedStyle(element).color);
|
|
const background = { r: 250, g: 252, b: 254 };
|
|
return contrastRatio(foreground, background);
|
|
|
|
function parseColor(color: string) {
|
|
const channels = color.match(/\d+(?:\.\d+)?/g)?.map(Number) ?? [0, 0, 0];
|
|
return { r: channels[0] ?? 0, g: channels[1] ?? 0, b: channels[2] ?? 0 };
|
|
}
|
|
|
|
function contrastRatio(
|
|
first: { r: number; g: number; b: number },
|
|
second: { r: number; g: number; b: number }
|
|
) {
|
|
const lighter = Math.max(luminance(first), luminance(second));
|
|
const darker = Math.min(luminance(first), luminance(second));
|
|
return (lighter + 0.05) / (darker + 0.05);
|
|
}
|
|
|
|
function luminance(color: { r: number; g: number; b: number }) {
|
|
const [r, g, b] = [color.r, color.g, color.b].map((channel) => {
|
|
const normalized = channel / 255;
|
|
return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
|
|
});
|
|
return 0.2126 * (r ?? 0) + 0.7152 * (g ?? 0) + 0.0722 * (b ?? 0);
|
|
}
|
|
});
|
|
|
|
expect(contrast).toBeGreaterThanOrEqual(4.5);
|
|
}
|