import { expect, test, type Locator, type Page } from "@playwright/test"; import { mockInvestigationPresentation } from "./support/mock-investigation-presentation"; test.use({ reducedMotion: "reduce" }); async function themeColor(element: Locator, token: string, property: string) { return cssColor(element, `var(${token})`, property); } async function cssColor(element: Locator, color: string, property: string) { return element.evaluate((node, { color, property }) => { const probe = document.createElement("span"); probe.style.setProperty(property, color); node.append(probe); const value = getComputedStyle(probe).getPropertyValue(property); probe.remove(); return value; }, { color, property }); } async function expectOrdinaryInteraction(row: Locator, label?: string, description?: string) { await expect(row).toHaveCSS("background-color", await themeColor(row, "--surface-reading", "background-color")); await expect(row).toHaveCSS("border-top-color", await themeColor(row, "--surface-border", "border-top-color")); await expect(row).toHaveCSS("color", await themeColor(row, "--action-blue-hover", "color")); if (label) await expect(row.getByText(label, { exact: true })).toHaveCSS("color", await themeColor(row, "--action-blue-hover", "color")); if (await row.locator("svg").count()) await expect(row.locator("svg").first()).toHaveCSS("color", await themeColor(row, "--action-blue-hover", "color")); if (description) await expect(row.getByText(description, { exact: true })).toHaveCSS("color", await themeColor(row, "--ink-muted", "color")); } async function appearance(row: Locator) { return row.evaluate((node) => { const style = getComputedStyle(node); return { background: style.backgroundColor, border: style.borderTopColor, color: style.color }; }); } async function expectPreservedOnHover(page: Page, row: Locator, selected = false) { await page.mouse.move(1, 1); const before = await appearance(row); await row.scrollIntoViewIfNeeded(); const bounds = (await row.boundingBox())!; await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2); if (selected) { await expect(row).toHaveCSS("background-color", before.background); await expect(row).toHaveCSS("color", before.color); await expect(row).toHaveCSS("border-top-color", await themeColor(row, "--action-border-hover", "border-top-color")); } else { await expect.poll(() => appearance(row)).toEqual(before); } } async function expectContained(row: Locator, width: number) { const bounds = (await row.boundingBox())!; expect(bounds.x).toBeGreaterThanOrEqual(0); expect(bounds.x + bounds.width).toBeLessThanOrEqual(width); expect(await row.evaluate((node) => node.scrollWidth - node.clientWidth)).toBeLessThanOrEqual(1); } for (const width of [1440, 375]) { test(`map options share hover and keyboard colors while keeping selected and disabled states at ${width}px`, async ({ page }, testInfo) => { test.setTimeout(90_000); await page.setViewportSize({ width, height: 900 }); const mock = await mockInvestigationPresentation(page, []); // Enable an alternate basemap with a synthetic token; every tile stays local. await page.route("**/runtime-config.js", (route) => route.fulfill({ contentType: "application/javascript", body: `globalThis.__TJWATER_CONFIG__=${JSON.stringify({ TJWATER_AUTH_MODE: "disabled", TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid", TJWATER_AGENT_URL: "http://127.0.0.1:8787", TJWATER_ENABLE_DEV_PANEL: "true", TJWATER_MAP_URL: "https://workbench-map.invalid/geoserver", TJWATER_GEOSERVER_WORKSPACE: "tjwater_next", TJWATER_MAPBOX_ACCESS_TOKEN: "", TJWATER_TIANDITU_TOKEN: "synthetic-browser-test", TJWATER_ENABLE_MSW: "false" })};` })); await page.route("https://*.tianditu.gov.cn/**", (route) => route.fulfill({ contentType: "image/png", body: Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABpfZFQAAAAABJRU5ErkJggg==", "base64") })); await page.goto("/", { waitUntil: "domcontentloaded" }); await page.waitForFunction(() => Boolean(window.__waterNetworkMap?.getLayer("scada-pressure-hit"))); await page.getByRole("button", { name: "更多:更多地图操作", exact: true }).click(); const more = page.getByRole("region", { name: "更多", exact: true }); const refresh = more.getByRole("button", { name: /刷新瓦片/ }); const exportView = more.getByRole("button", { name: /当前分辨率/ }); const restingRefresh = await appearance(refresh); const restingSibling = await appearance(exportView); await refresh.hover(); await expectOrdinaryInteraction(refresh, "刷新瓦片", "请求地图重新渲染"); expect(await appearance(exportView)).toEqual(restingSibling); await exportView.hover(); await expect.poll(() => appearance(refresh)).toEqual(restingRefresh); await expectOrdinaryInteraction(exportView, "当前分辨率", "按当前地图画布尺寸导出"); await refresh.hover(); await expectContained(more, width); await testInfo.attach(`more-hover-${width}`, { body: await more.screenshot({ path: testInfo.outputPath(`more-hover-${width}.png`) }), contentType: "image/png" }); await page.getByRole("button", { name: "测量:距离与范围测量", exact: true }).click(); const measure = page.getByRole("region", { name: "测量", exact: true }); const distance = measure.getByRole("button", { name: "距离", exact: true }); const area = measure.getByRole("button", { name: "面积", exact: true }); await expect(distance).toHaveAttribute("aria-pressed", "true"); await expectPreservedOnHover(page, distance, true); await area.hover(); await expectOrdinaryInteraction(area, "面积"); const meters = measure.getByRole("button", { name: "m", exact: true }); const kilometers = measure.getByRole("button", { name: "km", exact: true }); await expectPreservedOnHover(page, kilometers, true); await meters.hover(); await expectOrdinaryInteraction(meters); const clear = measure.getByRole("button", { name: "清除测量", exact: true }); await expect(clear).toBeDisabled(); await expectPreservedOnHover(page, clear); const start = measure.getByRole("button", { name: /开始测量/ }); await start.hover(); await expect(start).toHaveCSS("background-color", await themeColor(start, "--action-blue-hover", "background-color")); await expect(start).toHaveCSS("color", "rgb(255, 255, 255)"); await expect(start.getByText("开始测量", { exact: true })).toHaveCSS("color", "rgb(255, 255, 255)"); await expect(start.locator("svg").first()).toHaveCSS("color", "rgb(255, 255, 255)"); await expect(start.getByText("在地图中连续取点", { exact: true })).toHaveCSS("color", await cssColor(start, "color-mix(in oklab, white 85%, transparent)", "color")); await start.click(); for (const close of await page.getByRole("button", { name: "关闭通知", exact: true }).all()) { if (await close.isVisible()) await close.click(); } const canvas = page.locator("canvas.maplibregl-canvas"); await canvas.click({ position: { x: 100, y: 175 }, timeout: 10_000 }); await canvas.click({ position: { x: 150, y: 205 }, timeout: 10_000 }); const copy = measure.getByRole("button", { name: "复制结果", exact: true }); await expect(copy).toBeEnabled(); await copy.hover(); await expectOrdinaryInteraction(copy, "复制结果"); await expect(clear).toBeEnabled(); await clear.hover(); await expect(clear).toHaveCSS("background-color", await themeColor(clear, "--status-danger-soft", "background-color")); await expect(clear).toHaveCSS("border-top-color", await themeColor(clear, "--status-danger-border", "border-top-color")); await expect(clear).toHaveCSS("color", await themeColor(clear, "--status-danger-foreground", "color")); await expect(clear.locator("svg").first()).toHaveCSS("color", await themeColor(clear, "--status-danger-foreground", "color")); await expectContained(measure, width); await testInfo.attach(`measure-hover-${width}`, { body: await measure.screenshot({ path: testInfo.outputPath(`measure-hover-${width}.png`) }), contentType: "image/png" }); await page.getByRole("button", { name: "图层:管理地图图层", exact: true }).click(); const layers = page.getByRole("region", { name: "图层", exact: true }); const noBasemap = layers.getByRole("button", { name: "无底图", exact: true }); await noBasemap.scrollIntoViewIfNeeded(); await noBasemap.hover(); await expectOrdinaryInteraction(noBasemap, "无底图"); const vector = layers.getByRole("button", { name: "天地图矢量", exact: true }); await expect(vector).toHaveAttribute("aria-pressed", "true"); await expectPreservedOnHover(page, vector, true); const light = layers.getByRole("button", { name: "浅色", exact: true }); await expect(light).toBeDisabled(); await expectPreservedOnHover(page, light); await expectContained(layers, width); await testInfo.attach(`basemap-hover-${width}`, { body: await layers.screenshot({ path: testInfo.outputPath(`basemap-hover-${width}.png`) }), contentType: "image/png" }); await page.getByRole("button", { name: "图层:管理地图图层", exact: true }).click(); const header = page.locator(".workbench-top-bar"); const user = header.getByRole("button", { name: "打开用户菜单", exact: true }); await user.click(); const status = page.getByRole("menuitem", { name: /查看运行状态/ }); await status.hover(); await expectOrdinaryInteraction(status, "查看运行状态", "检查数据源与运行健康度"); const logout = page.getByRole("menuitem", { name: "退出登录", exact: true }); await expect(logout).toHaveAttribute("aria-disabled", "true"); await expectPreservedOnHover(page, logout); const userMenu = page.getByRole("menu"); await expectContained(userMenu, width); await page.keyboard.press("Escape"); await expect(userMenu).toHaveCount(0); await page.mouse.move(1, 1); await user.focus(); await expect(user).toBeFocused(); await user.press("ArrowDown"); await expect(userMenu).toBeVisible(); const menuItems = userMenu.getByRole("menuitem"); for (let index = 0; index < await menuItems.count(); index++) { await expect(menuItems.nth(index)).toBeFocused(); if (await status.evaluate((node) => document.activeElement === node)) break; await page.keyboard.press("ArrowDown"); } await expect(status).toBeFocused(); await expect(status).toHaveAttribute("data-highlighted", ""); await expectOrdinaryInteraction(status, "查看运行状态", "检查数据源与运行健康度"); await expect(status).toHaveCSS("outline-width", "1px"); await expect(status).toHaveCSS("outline-offset", "-1px"); await testInfo.attach(`header-keyboard-${width}`, { body: await userMenu.screenshot({ path: testInfo.outputPath(`header-keyboard-${width}.png`) }), contentType: "image/png" }); await page.keyboard.press("Escape"); await expect(userMenu).toHaveCount(0); // Dev tools are a desktop entry; verify the resulting window menu at the target width. if (width < 800) await page.setViewportSize({ width: 1440, height: 900 }); await page.getByRole("button", { name: "切换地图 Dev Panel", exact: true }).click(); const tools = page.getByTestId("investigation-dev-tools"); await tools.getByLabel("调查展示 JSONL", { exact: true }).fill(JSON.stringify({ record_type: "presentation", command: { action: "open_chart", chart_id: "hover-chart", evidence_refs: ["dev:synthetic"], chart: { title: "交互验证压力趋势", chart_type: "line", x_data: ["08:00", "08:15"], series: [{ name: "压力", data: [30, 28] }] } } })); await expect(tools.getByRole("combobox", { name: "JSONL 测试案例", exact: true })).toBeEnabled({ timeout: 15_000 }); await tools.getByRole("button", { name: "执行本地预览", exact: true }).click(); await expect(tools.getByRole("status")).toContainText("本地预览完成"); await page.getByLabel("本地预览展示", { exact: true }).getByRole("button", { name: "打开图表管理", exact: true }).click(); await expect(page.getByRole("dialog", { name: "图表管理", exact: true })).toBeVisible(); await page.setViewportSize({ width, height: 900 }); const navigator = header.getByRole("button", { name: /窗口管理,当前/ }); await navigator.click(); const windowMenu = page.getByRole("menu", { name: "已打开的窗口", exact: true }); const currentWindow = windowMenu.getByRole("menuitem", { name: /定位窗口 .*图表管理/ }); await expect(currentWindow).toHaveAttribute("aria-current", "true"); await expectPreservedOnHover(page, currentWindow, true); await expect(currentWindow).toHaveCSS("background-color", await themeColor(currentWindow, "--action-selection-soft", "background-color")); await expect(currentWindow).toHaveCSS("color", await themeColor(currentWindow, "--action-blue-hover", "color")); const closeWindows = windowMenu.getByRole("menuitem", { name: "关闭全部窗口", exact: true }); await closeWindows.hover(); await expectOrdinaryInteraction(closeWindows); await expectContained(windowMenu, width); await expectContained(header, width); expect(await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)).toBeLessThanOrEqual(1); await testInfo.attach(`window-hover-${width}`, { body: await windowMenu.screenshot({ path: testInfo.outputPath(`window-hover-${width}.png`) }), contentType: "image/png" }); expect(mock.mutations).toEqual([]); }); } test.describe("touch map options", () => { test.use({ hasTouch: true, isMobile: true, viewport: { width: 375, height: 812 } }); test("touch taps keep ordinary rows free of sticky hover colors", async ({ page }, testInfo) => { await mockInvestigationPresentation(page, []); await page.goto("/", { waitUntil: "domcontentloaded" }); expect(await page.evaluate(() => matchMedia("(hover: hover)").matches)).toBe(false); await page.getByRole("button", { name: "更多:更多地图操作", exact: true }).tap(); const more = page.getByRole("region", { name: "更多", exact: true }); const refresh = more.getByRole("button", { name: /刷新瓦片/ }); await refresh.tap({ trial: true }); const before = await appearance(refresh); const bounds = await refresh.boundingBox(); await refresh.tap(); await expect.poll(() => appearance(refresh)).toEqual(before); expect(await refresh.boundingBox()).toEqual(bounds); await expect(refresh).not.toHaveCSS("color", await themeColor(refresh, "--action-blue-hover", "color")); await expectContained(more, 375); await testInfo.attach("touch-options-375", { body: await more.screenshot({ path: testInfo.outputPath("touch-options-375.png") }), contentType: "image/png" }); }); });