Files
next-tjwater-frontend/tests/browser/workbench-visual.e2e.ts
T
jiang f4318a9b9c feat: refine workbench visuals and map controls
Unify the Agent history extension with the header acrylic surface, preserve the full conversation body, and consolidate shared control and status styling.

Restore map flow and SCADA controller behavior, remove obsolete rendering paths, and extend regression coverage. Button press coverage now releases outside the target so state assertions cannot accidentally toggle the control.
2026-07-28 16:39:36 +08:00

302 lines
12 KiB
TypeScript

import { expect, test, type Locator, type Page } from "@playwright/test";
const EMPTY_RASTER_TILE = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64"
);
test.describe("neutral blue mist 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 both floating panels legible", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await openWorkbench(page);
await expectDesktopFloatingGeometry(page);
await expectAcrylicAlphas(page, {
navigation: 0.64,
agentPanel: 0.82,
conditionPanel: 0.56,
control: 0.74
});
await expectLoadedChineseFont(page);
await expectBodyContrast(page);
await expect(page).toHaveScreenshot("workbench-desktop-light.png", {
animations: "disabled",
maxDiffPixelRatio: 0.01
});
});
test("desktop satellite basemap strengthens the same floating hierarchy", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await openWorkbench(page);
await selectBasemap(page, "影像");
await expect(page.locator("main")).toHaveAttribute("data-basemap-tone", "satellite");
await expectDesktopFloatingGeometry(page);
await expectAcrylicAlphas(page, {
navigation: 0.8,
agentPanel: 0.88,
conditionPanel: 0.72,
control: 0.86
});
await expect(page).toHaveScreenshot("workbench-desktop-satellite.png", {
animations: "disabled",
maxDiffPixelRatio: 0.01
});
});
test("wide desktop keeps a map corridor at maximum Agent width", async ({ page }) => {
await page.setViewportSize({ width: 1920, height: 1080 });
await openWorkbench(page);
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]');
const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true });
const resizeHandle = page.getByRole("separator", { name: "调整 Agent 面板宽度" });
await resizeHandle.focus();
await page.keyboard.press("End");
await expect(agentPanel).toHaveCSS("width", "720px");
await expectMapCorridor(agentPanel, conditionPanel, 680);
await conditionPanel.getByRole("button", { name: "展开工况任务" }).click();
await expect(conditionPanel).toHaveCSS("width", "960px");
await expect(agentPanel).toHaveCSS("width", "628px");
await expectMapCorridor(agentPanel, conditionPanel, 256);
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 page.route("**/runtime-config.js", async (route) => {
await route.fulfill({
contentType: "application/javascript",
body: `globalThis.__TJWATER_CONFIG__ = {
TJWATER_MAPBOX_ACCESS_TOKEN: "visual-test-token",
TJWATER_MAP_URL: "https://visual-map.invalid/geoserver",
TJWATER_GEOSERVER_WORKSPACE: "tjwater",
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://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();
});
}
async function openWorkbench(page: Page) {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.locator("main").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.waitForTimeout(350);
}
async function selectBasemap(page: Page, label: "浅色" | "影像") {
await page.getByRole("button", { name: /图层:管理地图图层/ }).click();
await page.getByRole("button", { name: label, exact: true }).click();
await page.getByRole("button", { name: /图层:管理地图图层/ }).click();
await page.waitForTimeout(220);
}
async function expectDesktopFloatingGeometry(page: Page) {
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]');
const agentHeader = agentPanel.locator(".agent-panel-integrated-header");
const agentComposer = agentPanel.locator(".agent-panel-composer");
const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true });
await expect(agentPanel).toHaveCSS("width", "500px");
await expect(conditionPanel).toHaveCSS("width", "432px");
await expect(agentPanel).toHaveCSS("border-radius", "16px");
await expect(agentHeader).toHaveCSS("border-radius", "0px");
await expect(agentComposer).toHaveCSS("border-radius", "16px");
await expect(agentComposer).toHaveCSS("border-top-width", "0px");
await expect(conditionPanel).toHaveCSS("border-radius", "16px");
const agentBox = await agentPanel.boundingBox();
const conditionBox = await conditionPanel.boundingBox();
expect(agentBox).not.toBeNull();
expect(conditionBox).not.toBeNull();
expect(agentBox!.x).toBe(12);
expect(agentBox!.y).toBe(96);
expect(agentBox!.y + agentBox!.height).toBe(884);
expect(conditionBox!.x).toBe(944);
expect(conditionBox!.y).toBe(96);
}
async function expectMapCorridor(
agentPanel: Locator,
conditionPanel: Locator,
minimumWidth: number
) {
const agentBox = await agentPanel.boundingBox();
const conditionBox = await conditionPanel.boundingBox();
expect(agentBox).not.toBeNull();
expect(conditionBox).not.toBeNull();
expect(conditionBox!.x - (agentBox!.x + agentBox!.width)).toBeGreaterThanOrEqual(minimumWidth);
}
async function expectLoadedChineseFont(page: Page) {
await expect
.poll(() =>
page.evaluate(() => document.fonts.check('16px "Noto Sans SC Variable"', "供水管网"))
)
.toBe(true);
}
async function expectAcrylicAlphas(
page: Page,
expected: {
navigation: number;
agentPanel: number;
conditionPanel: number;
control: number;
}
) {
const alphas = await page.evaluate(() => {
return {
navigation: alphaOf(document.querySelector(".acrylic-navigation")),
agentPanel: alphaOf(
document.querySelector('aside[aria-label="Agent 命令面板"]')
),
conditionPanel: alphaOf(document.querySelector(".scheduled-feed-panel-shell")),
control: alphaOf(document.querySelector(".acrylic-control"))
};
function alphaOf(element: Element | null) {
if (!element) {
return 1;
}
const color = getComputedStyle(element).backgroundColor;
const channels = color.match(/\d+(?:\.\d+)?/g)?.map(Number) ?? [];
return channels[3] ?? 1;
}
});
expect(alphas.navigation).toBeCloseTo(expected.navigation, 2);
expect(alphas.agentPanel).toBeCloseTo(expected.agentPanel, 2);
expect(alphas.conditionPanel).toBeCloseTo(expected.conditionPanel, 2);
expect(alphas.control).toBeCloseTo(expected.control, 2);
}
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);
}