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