Files
next-tjwater-frontend/tests/browser/geocoding.e2e.ts
T

344 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 });
}
test("location tabs preserve inputs and end map picking when switching to network features", async ({ page }) => {
await setup(page);
const panel = await openSearch(page);
await panel.getByLabel("查询地址").fill("南桥路");
const pick = panel.getByRole("button", { name: "从地图取点查找地名" });
await pick.click();
await expect(pick).toHaveAttribute("aria-pressed", "true");
await panel.getByRole("tab", { name: "管网要素" }).click();
await panel.getByLabel("要素 ID(可多个)").fill("P-17,P-28");
await panel.getByRole("tab", { name: "地址地名" }).click();
await expect(pick).toHaveAttribute("aria-pressed", "false");
await expect(panel.getByLabel("查询地址")).toHaveValue("南桥路");
await panel.getByRole("tab", { name: "管网要素" }).click();
await expect(panel.getByLabel("要素 ID(可多个)")).toHaveValue("P-17,P-28");
});
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<void>((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);
});