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"; import { expectThemeColor } from "./support/theme-colors"; const first = { id: "place-nanqiao-north", name: "南桥路", address: "上海市奉贤区南桥路1号", longitude: 121.458891, latitude: 30.916188, category: "道路名", district: "奉贤区" }; const second = { id: "place-nanqiao-south", name: "南桥路", address: "上海市奉贤区南桥路100号", longitude: 121.459301, latitude: 30.914195, category: "道路名", district: "奉贤区" }; const headers = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "Authorization, Content-Type, X-Project-Id", "Access-Control-Allow-Methods": "POST, OPTIONS" }; async function setup(page: Page) { await page.emulateMedia({ reducedMotion: "reduce" }); await mockWorkbenchContext(page, undefined, { metadata: { project_id: TEST_PROJECT_ID, name: "奉贤演示项目", code: "tjwater_v2", gs_workspace: "tjwater_next", status: "active", project_role: "member", map_config: { place_search: { enabled: true, provider: "tianditu", admin_code: "310120", region: "奉贤区" } } }, accessContext: { user_id: "operator", username: "operator", system_role: "user", is_system_admin: false, project_id: TEST_PROJECT_ID, project_role: "member", permissions: ["webgis.view"] } }); await mockAgentApi(page); } async function openSearch(page: Page) { await page.goto("/", { waitUntil: "domcontentloaded" }); const trigger = page.getByRole("button", { name: "地址定位:查询地址并定位到地图" }); await expect(trigger).toBeEnabled({ timeout: 20_000 }); await trigger.click(); return page.getByRole("region", { name: "地址定位", exact: true }); } for (const width of [1280, 1023, 375, 320]) { test(`place search selects and switches candidates at ${width}px`, async ({ page }) => { test.setTimeout(45_000); await page.setViewportSize({ width, height: 900 }); await setup(page); let calls = 0; await page.route("**/api/v1/place-search-requests", async (route) => { if (route.request().method() === "OPTIONS") { await route.fulfill({ status: 204, headers }); return; } expect(route.request().postDataJSON()).toEqual({ keyword: "南桥路" }); expect(route.request().headers()["x-project-id"]).toBe(TEST_PROJECT_ID); calls += 1; await route.fulfill({ headers, json: { items: [first, second], total: 2, truncated: false, region: "奉贤区" } }); }); const panel = await openSearch(page); await expect(panel).toContainText("奉贤区 · 天地图地名搜索"); const input = panel.getByLabel("查询地址"); const query = panel.getByRole("button", { name: "搜索地名" }); await expectThemeColor(input, "background-color", "--surface-reading"); const [inputBox, queryBox] = await Promise.all([input.boundingBox(), query.boundingBox()]); expect(inputBox!.height).toBe(40); expect(queryBox!.height).toBe(40); expect(inputBox!.y).toBe(queryBox!.y); await expect(query).toBeDisabled(); await input.fill(" 南桥路 "); await input.press("Enter"); const candidates = panel.getByRole("region", { name: "搜索结果" }); await expect(candidates.getByRole("button")).toHaveCount(2); const marker = page.locator('[data-geocoding-marker="true"]'); await expect(marker).toHaveCount(0); await expect(panel.getByRole("status")).toContainText("选择一个候选位置"); const resultHeader = candidates.locator(":scope > div").first(); const measureFrame = () => Promise.all([panel.boundingBox(), input.boundingBox(), resultHeader.boundingBox()]); const listFrame = await measureFrame(); const listViewport = await candidates.locator("ul").boundingBox(); const listName = candidates.locator("span.font-semibold").first(); const nameFontSize = await listName.evaluate((element) => getComputedStyle(element).fontSize); await page.screenshot({ path: test.info().outputPath(`place-candidates-${width}.png`) }); await candidates.getByRole("button", { name: "定位 南桥路 上海市奉贤区南桥路1号", exact: true }).click(); const selected = panel.getByRole("region", { name: "定位结果" }); await expect(selected).toContainText("上海市奉贤区南桥路1号"); await expect(selected.getByLabel("经纬度坐标").locator("dd")).toHaveText("121.458891, 30.916188"); await expect(marker).toHaveCount(1); await expect(candidates.getByRole("button", { name: "更换位置" })).toBeVisible(); await expect(candidates.locator("ul")).toHaveCount(0); const detailFrame = await measureFrame(); const detailViewport = await selected.boundingBox(); for (let index = 0; index < listFrame.length; index += 1) { expect(detailFrame[index]).toEqual(listFrame[index]); } expect(detailViewport).toEqual(listViewport); await expect(selected.locator("span.font-semibold")).toHaveCSS("font-size", nameFontSize); await candidates.getByRole("button", { name: "更换位置" }).click(); await expect(selected).toHaveCount(0); expect(await measureFrame()).toEqual(listFrame); await test.info().attach("list-detail-frame", { body: JSON.stringify({ width, listFrame, detailFrame, listViewport, detailViewport }), contentType: "application/json" }); await candidates.getByRole("button", { name: "定位 南桥路 上海市奉贤区南桥路100号", exact: true }).click(); await expect(selected).toContainText("上海市奉贤区南桥路100号"); await expect(selected.getByLabel("经纬度坐标").locator("dd")).toHaveText("121.459301, 30.914195"); await expect(marker).toHaveCount(1); await expect.poll(() => page.evaluate(() => { const center = window.__waterNetworkMap?.getCenter(); return center ? [Number(center.lng.toFixed(6)), Number(center.lat.toFixed(6))] : null; })).toEqual([121.459301, 30.914195]); await selected.getByRole("button", { name: "重新定位" }).scrollIntoViewIfNeeded(); await page.screenshot({ path: test.info().outputPath(`place-selected-${width}.png`) }); await selected.getByRole("button", { name: "重新定位" }).click(); expect(calls).toBe(1); await selected.getByRole("button", { name: "清除结果" }).click(); await expect(marker).toHaveCount(0); await expect(candidates).toHaveCount(0); await expect(input).toHaveValue(""); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width); }); } for (const width of [1280, 320]) { test(`ten candidates have no paging or checkmark and preserve keyboard focus at ${width}px`, async ({ page }) => { test.setTimeout(45_000); await page.setViewportSize({ width, height: width === 320 ? 812 : 900 }); await setup(page); const names = ["南桥饭店", "南桥饭店南楼", "南桥饭店东苑"]; const items = Array.from({ length: 10 }, (_, index) => ({ ...first, id: `hotel-test-${index}`, name: names[index] ?? `南桥饭店测试入口${index + 1}`, address: "南桥路111号", district: "奉贤区", category: "宾馆" })); await page.route("**/api/v1/place-search-requests", async (route) => { if (route.request().method() === "OPTIONS") { await route.fulfill({ status: 204, headers }); return; } await route.fulfill({ headers, json: { items, total: 10, truncated: true, region: "奉贤区" } }); }); const panel = await openSearch(page); await panel.getByLabel("查询地址").fill("南桥饭店"); await panel.getByLabel("查询地址").press("Enter"); const results = panel.getByRole("region", { name: "搜索结果" }); await expect(results).toContainText("前 10 个结果"); await expect(results).toContainText("最多显示 10 个位置,请细化关键词"); await expect(results.getByLabel("搜索结果分页")).toHaveCount(0); await expect(results.getByRole("button", { name: "上一页" })).toHaveCount(0); await expect(results.getByRole("button", { name: "下一页" })).toHaveCount(0); const list = results.locator("ul"); await expect(list.locator("li")).toHaveCount(10); await expect(list).toHaveCSS("background-color", "rgba(0, 0, 0, 0)"); await expect(list).toHaveCSS("border-left-width", "0px"); await expect(list).toHaveCSS("border-right-width", "0px"); const geometry = await list.evaluate((element) => { const viewport = element.getBoundingClientRect(); const rows = Array.from(element.querySelectorAll("li")).map((row) => row.getBoundingClientRect()); return { visibleRows: rows.filter((row) => row.top >= viewport.top && row.bottom <= viewport.bottom).length, rowHeight: rows[0].height, height: viewport.height, scrollable: element.scrollHeight > element.clientHeight }; }); expect(geometry.visibleRows).toBeGreaterThanOrEqual(width === 320 ? 2 : 4); expect(geometry.rowHeight).toBeGreaterThanOrEqual(64); expect(geometry.scrollable).toBe(true); const [panelBox, listBox] = await Promise.all([panel.boundingBox(), list.boundingBox()]); expect(listBox!.y + listBox!.height).toBeLessThanOrEqual(panelBox!.y + panelBox!.height - 10); await test.info().attach("candidate-geometry", { body: JSON.stringify(geometry), contentType: "application/json" }); await page.screenshot({ path: test.info().outputPath(`place-ten-candidates-${width}.png`) }); const target = results.getByRole("button", { name: "定位 南桥饭店 南桥路111号", exact: true }); await target.focus(); await page.keyboard.press("Enter"); const change = results.getByRole("button", { name: "更换位置" }); await expect(change).toBeFocused(); await expect(panel.getByRole("region", { name: "定位结果" })).toContainText("121.458891, 30.916188"); const detail = panel.getByRole("region", { name: "定位结果" }); await expectThemeColor(detail.locator("span.font-semibold"), "color", "--ink"); await expect(detail.locator("span.font-semibold").locator("xpath=../../..")) .toHaveCSS("background-color", "rgba(0, 0, 0, 0)"); await page.keyboard.press("Enter"); await expect(target).toBeFocused(); await expect(target).toHaveAttribute("aria-pressed", "true"); await expectThemeColor(target, "background-color", "--action-selection-soft"); await expectThemeColor(target.locator("span.font-semibold"), "color", "--action-blue-hover"); await expect(target.locator("svg")).toHaveCount(0); await page.screenshot({ path: test.info().outputPath(`place-color-selected-${width}.png`) }); const eighth = results.getByRole("button", { name: `定位 ${items[7].name} ${items[7].address}`, exact: true }); await eighth.scrollIntoViewIfNeeded(); await eighth.click(); await expect(detail).toContainText(items[7].name); await expect(detail.getByLabel("经纬度坐标")).toBeInViewport({ ratio: 1 }); const detailBox = await detail.boundingBox(); const actionBox = await detail.getByRole("button", { name: "重新定位" }).boundingBox(); expect(actionBox!.y + actionBox!.height).toBeLessThanOrEqual(detailBox!.y + detailBox!.height); await expect(detail.getByText("08", { exact: true })).toHaveCount(0); await expectThemeColor(detail.locator("span.font-semibold"), "color", "--ink"); await page.screenshot({ path: test.info().outputPath(`place-neutral-detail-${width}.png`) }); await change.click(); await expect(eighth).toHaveAttribute("aria-pressed", "true"); const isSelectedVisible = () => eighth.evaluate((element) => { const viewport = element.closest("ul")!.getBoundingClientRect(); const row = element.getBoundingClientRect(); return row.top >= viewport.top && row.bottom <= viewport.bottom; }); await expect.poll(isSelectedVisible).toBe(true); expect(await list.evaluate((element) => element.scrollTop)).toBeGreaterThan(0); await eighth.focus(); await page.keyboard.press("Enter"); await expect(change).toBeFocused(); await page.keyboard.press("Enter"); await expect(eighth).toBeFocused(); await expect.poll(isSelectedVisible).toBe(true); await page.screenshot({ path: test.info().outputPath(`place-return-to-selection-${width}.png`) }); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width); }); } test("place search reports errors and distinguishes an empty Fengxian search", async ({ page }) => { test.setTimeout(45_000); await page.setViewportSize({ width: 320, height: 700 }); await setup(page); let fail = true; let release!: () => void; const pending = new Promise((resolve) => { release = resolve; }); const queries: string[] = []; await page.route("**/api/v1/place-search-requests", async (route) => { if (route.request().method() === "OPTIONS") { await route.fulfill({ status: 204, headers }); return; } const body = route.request().postDataJSON() as { keyword: string }; queries.push(body.keyword); if (fail) { await pending; await route.fulfill({ status: 503, headers, json: { detail: "地名搜索服务暂不可用" } }); return; } const empty = body.keyword === "未知地名"; await route.fulfill({ headers, json: { items: empty ? [] : [first], total: empty ? 0 : 1, truncated: false, region: "奉贤区" } }); }); const panel = await openSearch(page); const input = panel.getByLabel("查询地址"); const query = panel.getByRole("button", { name: "搜索地名" }); await input.fill("南桥路"); await query.click(); await expect(panel.getByRole("status")).toContainText("正在搜索地名"); await expect(query).toBeDisabled(); release(); await expect(panel.getByRole("alert")).toContainText("地名搜索服务暂不可用"); fail = false; await query.click(); await expect(panel.getByRole("region", { name: "搜索结果" }).locator("li")).toHaveCount(1); expect(queries).toEqual(["南桥路", "南桥路"]); await input.fill("未知地名"); await query.click(); await expect(panel.getByRole("status")).toContainText("奉贤区内未找到匹配位置"); await expect(panel.getByRole("region", { name: "搜索结果" })).toHaveCount(0); await expect(page.locator('[data-geocoding-marker="true"]')).toHaveCount(0); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(320); await page.screenshot({ path: test.info().outputPath("place-empty-320.png") }); }); async function exposedMapPoint(page: Page) { return page.locator(".maplibregl-canvas").evaluate((canvas) => { const bounds = canvas.getBoundingClientRect(); for (const yFraction of [0.3, 0.35, 0.25, 0.45, 0.65]) { for (const xFraction of [0.5, 0.3, 0.65, 0.15]) { const x = bounds.left + bounds.width * xFraction; const y = bounds.top + bounds.height * yFraction; if (document.elementFromPoint(x, y) === canvas) return { x: bounds.width * xFraction, y: bounds.height * yFraction }; } } throw new Error("No exposed map point available"); }); } for (const width of [1280, 320]) { test(`map point lookup shows address without moving the camera at ${width}px`, async ({ page }, testInfo) => { await page.setViewportSize({ width, height: 900 }); await setup(page); let lastPoint: { longitude: number; latitude: number } | null = null; let pointLookups = 0; await page.route("**/api/v1/reverse-geocoding-requests", async (route) => { if (route.request().method() === "OPTIONS") { await route.fulfill({ status: 204, headers }); return; } expect(route.request().headers()["x-project-id"]).toBe(TEST_PROJECT_ID); pointLookups += 1; lastPoint = route.request().postDataJSON(); await route.fulfill({ headers, json: { ...first, id: "map-point", name: "南桥镇", address: "上海市奉贤区南桥镇南桥路", ...lastPoint, category: "附近地名" } }); }); const panel = await openSearch(page); const picker = panel.getByRole("button", { name: "从地图取点查找地名" }); await picker.click(); await expect(picker).toHaveAttribute("aria-pressed", "true"); await expect(panel.getByRole("status")).toContainText("点击地图查询地名"); const canvas = page.locator(".maplibregl-canvas"); await expect(canvas).toHaveCSS("cursor", "crosshair"); const before = await page.evaluate(() => ({ center: window.__waterNetworkMap!.getCenter().toArray(), zoom: window.__waterNetworkMap!.getZoom() })); await canvas.click({ position: await exposedMapPoint(page) }); const selected = panel.getByRole("region", { name: "定位结果" }); await expect(selected).toContainText("上海市奉贤区南桥镇南桥路"); expect(lastPoint).not.toBeNull(); const visibleCoordinates = await selected.getByLabel("经纬度坐标").locator("dd").evaluate((element) => { const scrollArea = element.closest(".overflow-y-auto")!; return element.getBoundingClientRect().bottom <= scrollArea.getBoundingClientRect().bottom; }); expect(visibleCoordinates).toBe(true); await expect(page.locator('[data-geocoding-marker="true"]')).toHaveCount(1); const after = await page.evaluate(() => ({ center: window.__waterNetworkMap!.getCenter().toArray(), zoom: window.__waterNetworkMap!.getZoom() })); expect(after).toEqual(before); await expect(panel.getByRole("button", { name: "更换位置" })).toHaveCount(0); await panel.screenshot({ path: testInfo.outputPath(`map-point-${width}.png`), animations: "disabled" }); await expect(picker).toHaveAttribute("aria-pressed", "false"); await expect(canvas).not.toHaveCSS("cursor", "crosshair"); await expect(selected).toBeVisible(); await canvas.click({ position: await exposedMapPoint(page) }); expect(pointLookups).toBe(1); await selected.getByRole("button", { name: "清除结果" }).click(); await expect(page.locator('[data-geocoding-marker="true"]')).toHaveCount(0); }); } test("map point lookup recovers from an out of scope point and stops when closing", async ({ page }) => { await setup(page); let calls = 0; await page.route("**/api/v1/reverse-geocoding-requests", async (route) => { if (route.request().method() === "OPTIONS") { await route.fulfill({ status: 204, headers }); return; } calls += 1; await route.fulfill({ headers, status: calls === 1 ? 422 : 200, json: calls === 1 ? { detail: "取点位置不在当前项目的地名搜索范围内(奉贤区)" } : { ...first, ...route.request().postDataJSON(), id: "map-retry" } }); }); const panel = await openSearch(page); await panel.getByRole("button", { name: "从地图取点查找地名" }).click(); const canvas = page.locator(".maplibregl-canvas"); await canvas.click({ position: { x: 450, y: 170 } }); await expect(panel.getByRole("alert")).toContainText("不在当前项目"); await expect(panel.getByRole("button", { name: "从地图取点查找地名" })).toHaveAttribute("aria-pressed", "true"); await canvas.click({ position: { x: 440, y: 180 } }); await expect(panel.getByRole("region", { name: "定位结果" })).toContainText("上海市奉贤区南桥路1号"); await expect(panel.getByRole("button", { name: "从地图取点查找地名" })).toHaveAttribute("aria-pressed", "false"); await page.getByRole("button", { name: "地址定位:查询地址并定位到地图" }).click(); await expect(panel).toBeHidden(); await expect(canvas).not.toHaveCSS("cursor", "crosshair"); await canvas.click({ position: { x: 430, y: 190 } }); expect(calls).toBe(2); });