import { expect, test, type Page } from "@playwright/test"; import { mockAgentApi } from "./support/mock-agent-api"; import { mockWorkbenchContext } from "./support/mock-workbench-context"; import { TEST_PROJECT_ID } from "./support/mock-scada-api"; const FENGYANG_ID = "b962c44e-8433-4a10-81e9-a64b70ac1152"; const projects = [ { project_id: TEST_PROJECT_ID, name: "奉贤演示项目", code: "tjwater_v2", gs_workspace: "tjwater_next", status: "active" }, { project_id: FENGYANG_ID, name: "凤阳(新版)", code: "fengyang_v2", gs_workspace: "fengyang_v2", status: "active" } ]; async function prepare(page: Page, denyFengyang = false, configureSearch = true) { await mockWorkbenchContext(page); await mockAgentApi(page); await page.route("https://tjwater-api.invalid/**", async (route) => { const request = route.request(); if (request.method() === "OPTIONS") return route.fallback(); const path = new URL(request.url()).pathname; const projectId = request.headers()["x-project-id"]; const project = projects.find((item) => item.project_id === projectId); const headers = { "Access-Control-Allow-Origin": "*" }; if (path === "/api/v1/projects") return route.fulfill({ headers, json: { items: projects, total: 2, limit: 1000, offset: 0 } }); if (!project) return route.fallback(); if (path === "/api/v1/projects/current/metadata") return route.fulfill({ headers, status: denyFengyang && projectId === FENGYANG_ID ? 403 : 200, json: { ...project, map_config: configureSearch ? { place_search: { enabled: true, provider: "tianditu", admin_code: projectId === FENGYANG_ID ? "341126" : "310120", region: projectId === FENGYANG_ID ? "凤阳县" : "奉贤区" } } : {}, map_extent: { bbox: projectId === FENGYANG_ID ? [13043968, 3846034, 13127803, 3889110] : [13508802, 3608164, 13555651, 3633686] }, project_role: "member" } }); if (path === "/api/v1/access-context") return route.fulfill({ headers, json: { user_id: "operator", username: "project.operator", system_role: "user", is_system_admin: false, project_id: projectId, project_role: "member", permissions: ["webgis.view"] } }); return route.fallback(); }); await page.emulateMedia({ reducedMotion: "reduce" }); } async function openSwitcher(page: Page, input: "pointer" | "keyboard" = "pointer") { const userMenu = page.getByRole("button", { name: "打开用户菜单" }); if (input === "keyboard") { await userMenu.press("Enter"); await page.getByRole("menuitem", { name: "切换项目" }).press("Enter"); } else { await userMenu.click(); await page.getByRole("menuitem", { name: "切换项目" }).click(); } const dialog = page.getByRole("dialog", { name: "切换项目" }); await expect(dialog).toBeVisible(); return dialog; } async function chooseProject(page: Page, name: string) { await page.getByRole("combobox", { name: "项目环境" }).click(); await page.getByRole("option").filter({ has: page.getByText(name, { exact: true }) }).click(); } for (const viewport of [{ width: 1440, height: 900 }, { width: 375, height: 812 }, { width: 320, height: 740 }]) { test(`switches projects through the dialog and select at ${viewport.width}px`, async ({ page }, testInfo) => { // This scenario boots the map three times and checks both dialog states. test.setTimeout(60_000); // Empty mock sources emit unrelated degradation notices; exclude them from design captures. const screenshotStyle = "[data-sonner-toaster] { visibility: hidden; }"; await page.setViewportSize(viewport); await prepare(page); const pageErrors: string[] = []; page.on("pageerror", (error) => pageErrors.push(error.message)); const requests: { url: string; project: string | undefined }[] = []; page.on("request", (request) => requests.push({ url: request.url(), project: request.headers()["x-project-id"] })); await page.goto("/", { waitUntil: "domcontentloaded" }); const dialog = await openSwitcher(page); await expect(dialog.getByRole("combobox")).toContainText("奉贤演示项目"); await expect(dialog.getByRole("heading", { name: "切换项目" })).toBeVisible(); await expect(dialog.getByRole("button", { name: "进入项目", exact: true })).toBeDisabled(); await chooseProject(page, "凤阳(新版)"); expect(requests.some((request) => request.project === FENGYANG_ID)).toBe(false); await dialog.screenshot({ path: testInfo.outputPath(`project-draft-${viewport.width}.png`), animations: "disabled", style: screenshotStyle }); await dialog.getByRole("button", { name: "进入项目", exact: true }).click(); await expect(dialog).toBeHidden(); await expect.poll(() => requests.some((request) => request.url.includes("/scada-devices") && request.project === FENGYANG_ID), { timeout: 10000 }).toBe(true); await expect.poll(() => requests.some((request) => request.url.includes("/api/v1/sessions") && request.project === FENGYANG_ID)).toBe(true); await expect.poll(() => page.evaluate(() => { const source = window.__waterNetworkMap?.getSource("pipes") as { tiles?: string[] } | undefined; return source?.tiles?.[0] ?? ""; })).toContain("fengyang_v2:pipes"); await expect.poll(() => page.evaluate(() => window.__waterNetworkMap?.getCenter().lng ?? 0), { timeout: 15_000 }).toBeGreaterThan(117); await expect.poll(() => page.evaluate(() => window.__waterNetworkMap?.getCenter().lng ?? 999), { timeout: 15_000 }).toBeLessThan(119); const currentDialog = await openSwitcher(page); await expect(currentDialog.getByRole("combobox")).toContainText("凤阳(新版)"); const bounds = await currentDialog.boundingBox(); expect(bounds).not.toBeNull(); expect(bounds!.x).toBeGreaterThanOrEqual(15); expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(viewport.width - 15); await page.screenshot({ path: testInfo.outputPath(`project-dialog-${viewport.width}.png`), animations: "disabled", style: screenshotStyle }); await currentDialog.screenshot({ path: testInfo.outputPath(`project-dialog-detail-${viewport.width}.png`), animations: "disabled", style: screenshotStyle }); await currentDialog.getByRole("combobox").click(); await expect(page.getByRole("listbox")).toBeVisible(); await page.screenshot({ path: testInfo.outputPath(`project-options-${viewport.width}.png`), animations: "disabled", style: screenshotStyle }); await page.keyboard.press("Escape"); await page.keyboard.press("Escape"); await page.reload({ waitUntil: "domcontentloaded" }); const restoredDialog = await openSwitcher(page); await expect(restoredDialog.getByRole("combobox")).toContainText("凤阳(新版)"); await chooseProject(page, "奉贤演示项目"); await restoredDialog.getByRole("button", { name: "进入项目", exact: true }).click(); await expect.poll(() => page.evaluate(() => { const source = window.__waterNetworkMap?.getSource("pipes") as { tiles?: string[] } | undefined; return source?.tiles?.[0] ?? ""; })).toContain("tjwater_next:pipes"); expect(pageErrors).toEqual([]); }); } test("keeps the dialog and current project when target access fails", async ({ page }) => { await prepare(page, true); await page.goto("/", { waitUntil: "domcontentloaded" }); const dialog = await openSwitcher(page); await dialog.getByRole("combobox").press("ArrowDown"); await page.getByRole("option").filter({ has: page.getByText("凤阳(新版)", { exact: true }) }).focus(); await page.keyboard.press("Enter"); await dialog.getByRole("button", { name: "进入项目", exact: true }).focus(); await page.keyboard.press("Enter"); await expect(dialog.getByRole("alert")).toContainText("目标项目暂时无法访问"); await expect(dialog.getByText("当前项目:奉贤演示项目")).toBeVisible(); await expect(dialog.getByRole("combobox")).toContainText("凤阳(新版)"); }); for (const reducedMotion of ["reduce", "no-preference"] as const) { test(`discards a draft selection and restores focus with motion=${reducedMotion}`, async ({ page }) => { await prepare(page); await page.emulateMedia({ reducedMotion }); await page.goto("/", { waitUntil: "domcontentloaded" }); const dialog = await openSwitcher(page, reducedMotion === "reduce" ? "keyboard" : "pointer"); await expect(dialog.getByRole("combobox")).toBeFocused(); await expect(dialog.getByRole("button", { name: "关闭项目切换" })).not.toBeFocused(); await expect(page.getByRole("listbox")).toBeHidden(); await expect(dialog).toHaveCSS("opacity", "1"); await expect(dialog).toHaveCSS("animation-name", "none"); await page.keyboard.press("Shift+Tab"); await expect(dialog.getByRole("button", { name: "关闭项目切换" })).toBeFocused(); await page.keyboard.press("Tab"); await expect(dialog.getByRole("combobox")).toBeFocused(); await dialog.getByRole("combobox").click(); await expect(page.getByRole("listbox")).toHaveCSS("animation-name", "none"); await page.getByRole("option").filter({ has: page.getByText("凤阳(新版)", { exact: true }) }).click(); await dialog.getByRole("button", { name: "关闭项目切换" }).click(); await expect(dialog).toBeHidden(); await expect(page.getByRole("button", { name: "打开用户菜单" })).toBeFocused(); const reopened = await openSwitcher(page); await expect(reopened.getByRole("combobox")).toContainText("奉贤演示项目"); await expect(reopened.getByRole("combobox")).toBeFocused(); await page.keyboard.press("Escape"); await expect(reopened).toBeHidden(); }); } test("ignores a remembered project that is absent from the authorized list", async ({ page }) => { await prepare(page); await page.addInitScript(() => localStorage.setItem("tjwater:active-project", "not-authorized")); const projectIds: string[] = []; page.on("request", (request) => { const project = request.headers()["x-project-id"]; if (project) projectIds.push(project); }); await page.goto("/", { waitUntil: "domcontentloaded" }); const dialog = await openSwitcher(page); await expect(dialog.getByRole("combobox")).toContainText("奉贤演示项目"); expect(projectIds).not.toContain("not-authorized"); expect(projectIds).toContain(TEST_PROJECT_ID); }); test("opens the project list without a transient outer scrollbar", async ({ page }, testInfo) => { await prepare(page); await page.emulateMedia({ reducedMotion: "no-preference" }); await page.goto("/", { waitUntil: "domcontentloaded" }); const dialog = await openSwitcher(page); await expect(dialog).toHaveCSS("opacity", "1"); const sampling = page.evaluate(() => new Promise<{ height: number; scrollHeight: number; overflow: string; viewportTransform: string; }[]>((resolve, reject) => { const frames: { height: number; scrollHeight: number; overflow: string; viewportTransform: string }[] = []; const timeout = setTimeout(() => { observer.disconnect(); reject(new Error("Project list did not open")); }, 10_000); const observer = new MutationObserver(() => { const content = document.querySelector('[role="listbox"]'); if (!content) return; observer.disconnect(); const start = performance.now(); const sample = () => { const viewport = content.querySelector('[data-radix-select-viewport]'); frames.push({ height: content.clientHeight, scrollHeight: content.scrollHeight, overflow: getComputedStyle(content).overflowY, viewportTransform: viewport ? getComputedStyle(viewport).transform : "missing" }); if (performance.now() - start < 350) requestAnimationFrame(sample); else { clearTimeout(timeout); resolve(frames); } }; sample(); }); observer.observe(document.body, { childList: true, subtree: true }); })); await dialog.getByRole("combobox").click(); const samples = await sampling; await testInfo.attach("first-open-scroll-frames", { body: JSON.stringify(samples, null, 2), contentType: "application/json" }); expect(samples.length).toBeGreaterThan(1); expect(samples.filter((sample) => ["auto", "scroll"].includes(sample.overflow) && sample.scrollHeight > sample.height)).toEqual([]); await expect(page.getByRole("option")).toHaveCount(2); await page.keyboard.press("Escape"); await expect(dialog.getByRole("combobox")).toBeFocused(); }); test("keeps long project lists scrollable with keyboard selection", async ({ page }) => { await prepare(page); await page.emulateMedia({ reducedMotion: "no-preference" }); const catalog = [...projects, ...Array.from({ length: 40 }, (_, index) => ({ project_id: `project-${index}`, name: `测试供水项目 ${index + 1}`, code: `project_${index}`, gs_workspace: `project_${index}`, status: "active" }))]; await page.route("https://tjwater-api.invalid/api/v1/projects?**", (route) => { if (route.request().method() === "OPTIONS") return route.fallback(); return route.fulfill({ headers: { "Access-Control-Allow-Origin": "*" }, json: { items: catalog, total: catalog.length, limit: 1000, offset: 0 } }); }); await page.goto("/", { waitUntil: "domcontentloaded" }); const dialog = await openSwitcher(page); await dialog.getByRole("combobox").click(); await expect(page.getByRole("option")).toHaveCount(catalog.length); const viewport = page.locator("[data-radix-select-viewport]"); await expect.poll(() => viewport.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true); await page.getByRole("option").first().press("End"); await expect(page.getByRole("option").last()).toBeFocused(); await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBeGreaterThan(0); await page.keyboard.press("Enter"); await expect(dialog.getByRole("combobox")).toContainText("测试供水项目 40"); }); for (const input of ["pointer", "keyboard"] as const) { test(`selects a project without a dark dialog focus outline using ${input}`, async ({ page }, testInfo) => { await prepare(page); await page.emulateMedia({ reducedMotion: "no-preference" }); await page.goto("/", { waitUntil: "domcontentloaded" }); const dialog = await openSwitcher(page, input); await expect(dialog).toHaveCSS("opacity", "1"); const trace = await dialog.evaluateHandle((element) => { const samples: { isDialog: boolean; tag: string; outline: string; width: number; color: string }[] = []; element.addEventListener("focusin", (event) => { const target = event.target as HTMLElement; const style = getComputedStyle(element); samples.push({ isDialog: target === element, tag: target.tagName, outline: style.outlineStyle, width: parseFloat(style.outlineWidth), color: style.outlineColor }); }); return samples; }); const trigger = dialog.getByRole("combobox"); await expect(trigger).toBeFocused(); const focusedBorder = await trigger.evaluate((element) => getComputedStyle(element).borderTopColor); if (input === "keyboard") { await trigger.press("ArrowDown"); await page.getByRole("option").filter({ has: page.getByText("凤阳(新版)", { exact: true }) }).focus(); await page.keyboard.press("Enter"); } else await chooseProject(page, "凤阳(新版)"); await expect(trigger).toContainText("凤阳(新版)"); await expect(trigger).toBeFocused(); await expect(trigger).toHaveCSS("border-top-color", focusedBorder); const samples = await trace.jsonValue(); await trace.dispose(); await testInfo.attach("project-option-focus-trace", { body: JSON.stringify(samples, null, 2), contentType: "application/json" }); expect(samples.length).toBeGreaterThan(0); expect(samples.filter((sample) => sample.isDialog && sample.width > 0 && sample.outline !== "none")).toEqual([]); await expect(dialog.getByRole("button", { name: "进入项目", exact: true })).toBeEnabled(); }); } test("place search follows project configuration after switching", async ({ page }, testInfo) => { await prepare(page); await page.route("**/api/v1/place-search-requests", async (route) => { if (route.request().method() === "OPTIONS") return route.fallback(); expect(route.request().headers()["x-project-id"]).toBe(FENGYANG_ID); expect(route.request().postDataJSON()).toEqual({ keyword: "府城镇" }); return route.fulfill({ headers: { "Access-Control-Allow-Origin": "*" }, json: { items: [{ id: "fengyang-place", name: "府城镇", address: "安徽省滁州市凤阳县府城镇", longitude: 117.56, latitude: 32.86, category: "行政区", district: "凤阳县" }], total: 1, truncated: false, region: "凤阳县" } }); }); await page.goto("/", { waitUntil: "domcontentloaded" }); const dialog = await openSwitcher(page); await chooseProject(page, "凤阳(新版)"); await dialog.getByRole("button", { name: "进入项目", exact: true }).click(); await expect(dialog).toBeHidden(); await page.getByRole("button", { name: "地址定位:查询地址并定位到地图" }).click(); const panel = page.getByRole("region", { name: "地址定位", exact: true }); await expect(panel).toContainText("凤阳县 · 天地图地名搜索"); await expect(panel).not.toContainText("奉贤区"); await panel.getByLabel("查询地址").fill("府城镇"); await panel.getByRole("button", { name: "搜索地名" }).click(); await panel.getByRole("button", { name: "定位 府城镇 安徽省滁州市凤阳县府城镇", exact: true }).click(); await expect(page.locator('[data-geocoding-marker="true"]')).toHaveCount(1); await expect(panel.getByRole("region", { name: "定位结果" })).toContainText("117.560000, 32.860000"); await panel.screenshot({ path: testInfo.outputPath("fengyang-place-search.png"), animations: "disabled" }); }); test("unconfigured project disables place search", async ({ page }) => { await prepare(page, false, false); await page.goto("/", { waitUntil: "domcontentloaded" }); await page.getByRole("button", { name: "地址定位:查询地址并定位到地图" }).click(); const panel = page.getByRole("region", { name: "地址定位", exact: true }); await expect(panel).toContainText("当前项目暂未配置地名搜索"); await expect(panel.getByRole("button", { name: "搜索地名" })).toBeDisabled(); });