Derive the gem-blue palette with OKLCH tonal and complementary-color rules while preserving acrylic opacity, blur and surface hierarchy. Darker focus tokens exposed stacked field rings, fallback outlines and persistent basemap selection rings. Keep fallback focus styles in the base layer and use one themed field boundary or inset card stroke. Preserve keyboard focus, invalid states and native input semantics. Add browser regression coverage for pointer and keyboard focus, all basemap presentations, narrow layouts and rendered contrast. Validation in an isolated checkout: pnpm lint, pnpm typecheck, pnpm test (393 tests), focused browser suites and pnpm build.
237 lines
9.6 KiB
TypeScript
237 lines
9.6 KiB
TypeScript
import { expect, test, type Page } from "@playwright/test";
|
|
import { mockScadaApi } from "./support/mock-scada-api";
|
|
import { readThemeColor } from "./support/theme-colors";
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
await prepareWorkbench(page);
|
|
});
|
|
|
|
test("desktop controls share focus, hover, press and open states", async ({ page }) => {
|
|
await page.setViewportSize({ width: 1440, height: 900 });
|
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
|
|
|
const conditionFeed = page.getByRole("button", { name: "工况任务" });
|
|
await expect(conditionFeed).toBeVisible();
|
|
await expect.poll(async () => (await conditionFeed.boundingBox())?.height).toBe(32);
|
|
|
|
const compactHitArea = await conditionFeed.evaluate(
|
|
(element) => getComputedStyle(element, "::after").inset
|
|
);
|
|
expect(compactHitArea).toBe("-4px");
|
|
|
|
await conditionFeed.focus();
|
|
await expect
|
|
.poll(async () => conditionFeed.evaluate((element) => getComputedStyle(element).boxShadow))
|
|
.not.toBe("none");
|
|
|
|
await conditionFeed.hover();
|
|
await expect
|
|
.poll(async () => conditionFeed.evaluate((element) => getComputedStyle(element).backgroundColor))
|
|
.not.toBe("rgba(0, 0, 0, 0)");
|
|
|
|
const pressTarget = page.getByRole("button", { name: /任务浮条/ });
|
|
const restingBackground = await pressTarget.evaluate(
|
|
(element) => getComputedStyle(element).backgroundColor
|
|
);
|
|
expect(restingBackground).toBe(await readThemeColor(pressTarget, "--action-selection-soft"));
|
|
await pressTarget.hover();
|
|
const box = await pressTarget.boundingBox();
|
|
expect(box).not.toBeNull();
|
|
await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2);
|
|
await page.mouse.down();
|
|
await expect
|
|
.poll(async () => pressTarget.evaluate((element) => element.matches(":active")))
|
|
.toBe(true);
|
|
await expect
|
|
.poll(async () => pressTarget.evaluate((element) => getComputedStyle(element).filter))
|
|
.toBe("none");
|
|
const pressedBox = await pressTarget.boundingBox();
|
|
expect(pressedBox).toEqual(box);
|
|
await expect
|
|
.poll(async () => pressTarget.evaluate((element) => getComputedStyle(element).backgroundColor))
|
|
.toBe(await readThemeColor(pressTarget, "--action-selection-soft-pressed"));
|
|
await page.mouse.move(720, 460);
|
|
await page.mouse.up();
|
|
await expect
|
|
.poll(async () => pressTarget.evaluate((element) => getComputedStyle(element).filter))
|
|
.toBe("none");
|
|
await expect(pressTarget).toHaveAttribute("aria-pressed", "true");
|
|
await pressTarget.hover();
|
|
const hoverBackground = await pressTarget.evaluate((element) => {
|
|
const probe = document.createElement("span");
|
|
probe.style.backgroundColor = "var(--action-selection-soft-hover)";
|
|
element.append(probe);
|
|
const backgroundColor = getComputedStyle(probe).backgroundColor;
|
|
probe.remove();
|
|
return backgroundColor;
|
|
});
|
|
await expect
|
|
.poll(async () => pressTarget.evaluate((element) => getComputedStyle(element).backgroundColor))
|
|
.toBe(hoverBackground);
|
|
await page.mouse.move(720, 460);
|
|
await expect
|
|
.poll(async () => pressTarget.evaluate((element) => getComputedStyle(element).backgroundColor))
|
|
.toBe(restingBackground);
|
|
await page.getByRole("button", { name: "打开用户菜单" }).click();
|
|
const userButton = page.getByRole("button", { name: "打开用户菜单" });
|
|
await expect(userButton).toHaveAttribute("data-state", "open");
|
|
await expect
|
|
.poll(async () => userButton.evaluate((element) => getComputedStyle(element).backgroundColor))
|
|
.not.toBe("rgba(0, 0, 0, 0)");
|
|
|
|
const mapTool = page.getByRole("button", { name: /图层:管理地图图层/ });
|
|
const mapToolBox = await mapTool.boundingBox();
|
|
expect(mapToolBox?.width).toBe(40);
|
|
expect(mapToolBox?.height).toBe(40);
|
|
});
|
|
|
|
test("mobile launchers and map dock keep non-overlapping touch targets", async ({ page }) => {
|
|
await page.setViewportSize({ width: 375, height: 812 });
|
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
|
|
|
const agentLauncher = page.getByRole("button", { name: "打开 Agent 面板" });
|
|
const conditionLauncher = page.getByRole("button", { name: "打开工况任务" });
|
|
const mapTools = page.locator('nav[aria-label="地图工具"]:visible button:visible');
|
|
|
|
for (const locator of [agentLauncher, conditionLauncher]) {
|
|
const box = await locator.boundingBox();
|
|
expect(box?.width).toBeGreaterThanOrEqual(44);
|
|
expect(box?.height).toBeGreaterThanOrEqual(44);
|
|
}
|
|
|
|
const toolCount = await mapTools.count();
|
|
expect(toolCount).toBeGreaterThan(0);
|
|
for (let index = 0; index < toolCount; index += 1) {
|
|
const box = await mapTools.nth(index).boundingBox();
|
|
expect(box?.width).toBeGreaterThanOrEqual(40);
|
|
expect(box?.height).toBeGreaterThanOrEqual(40);
|
|
}
|
|
|
|
const rectangles = await Promise.all(
|
|
[agentLauncher, mapTools.first(), conditionLauncher].map((locator) => locator.boundingBox())
|
|
);
|
|
expect(rectangles.every(Boolean)).toBe(true);
|
|
expect(rectangles[0]!.x + rectangles[0]!.width).toBeLessThanOrEqual(rectangles[1]!.x);
|
|
expect(rectangles[1]!.x + rectangles[1]!.width).toBeLessThan(rectangles[2]!.x);
|
|
});
|
|
|
|
test("mobile timeline only offers collapse after opening its detail sheet", async ({ page }) => {
|
|
await page.setViewportSize({ width: 375, height: 812 });
|
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
|
|
|
const timelineSummary = page.getByRole("region", { name: "移动端运行时间轴摘要" });
|
|
await expect(timelineSummary).toBeVisible();
|
|
await expect(timelineSummary.getByRole("button", { name: "隐藏运行时间轴" })).toHaveCount(0);
|
|
await timelineSummary.getByRole("button", { name: "打开完整运行时间轴" }).click();
|
|
|
|
const timelineSheet = page.getByRole("region", { name: "运行时间轴抽屉" });
|
|
await expect(timelineSheet).toBeVisible();
|
|
await timelineSheet.getByRole("button", { name: "收起时间轴" }).click();
|
|
await expect(timelineSheet).toBeHidden();
|
|
await expect(timelineSummary).toBeVisible();
|
|
});
|
|
|
|
test("mobile map tool panel stays above the bottom dock", async ({ page }) => {
|
|
await page.setViewportSize({ width: 375, height: 812 });
|
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
|
|
|
await page.getByRole("button", { name: "更多:更多地图操作" }).click();
|
|
|
|
const toolPanel = page.getByRole("region", { name: "更多" });
|
|
const mapDock = page.locator('nav[aria-label="地图工具"]:visible');
|
|
await expect(toolPanel).toBeVisible();
|
|
await expect
|
|
.poll(async () => {
|
|
const [panelBox, dockBox] = await Promise.all([
|
|
toolPanel.boundingBox(),
|
|
mapDock.boundingBox()
|
|
]);
|
|
if (!panelBox || !dockBox) return Number.POSITIVE_INFINITY;
|
|
return Math.round(panelBox.y + panelBox.height - dockBox.y);
|
|
})
|
|
.toBeLessThanOrEqual(-8);
|
|
});
|
|
|
|
test("mobile map zoom stays above the bottom dock", async ({ page }) => {
|
|
await page.setViewportSize({ width: 375, height: 812 });
|
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
|
|
|
const mapZoom = page.getByRole("group", { name: "地图缩放" });
|
|
const mapDock = page.locator('nav[aria-label="地图工具"]:visible');
|
|
await expect(mapZoom).toBeVisible();
|
|
await expect
|
|
.poll(async () => {
|
|
const [zoomBox, dockBox] = await Promise.all([
|
|
mapZoom.boundingBox(),
|
|
mapDock.boundingBox()
|
|
]);
|
|
if (!zoomBox || !dockBox) return Number.POSITIVE_INFINITY;
|
|
return Math.round(zoomBox.y + zoomBox.height - dockBox.y);
|
|
})
|
|
.toBeLessThanOrEqual(-8);
|
|
|
|
const zoomBox = await mapZoom.boundingBox();
|
|
expect(Math.round(375 - zoomBox!.x - zoomBox!.width)).toBe(12);
|
|
});
|
|
|
|
test("Agent content buttons do not gain a border while pressed", async ({ page }) => {
|
|
await page.setViewportSize({ width: 1440, height: 900 });
|
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
|
|
|
await page.evaluate(() => {
|
|
const conversation = document.createElement("div");
|
|
conversation.className = "agent-panel-conversation";
|
|
Object.assign(conversation.style, {
|
|
position: "fixed",
|
|
inset: "100px auto auto 100px",
|
|
zIndex: "9999"
|
|
});
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.textContent = "测试内容按钮";
|
|
Object.assign(button.style, { width: "120px", height: "40px" });
|
|
conversation.append(button);
|
|
document.body.append(conversation);
|
|
});
|
|
|
|
const contentButton = page.getByRole("button", { name: "测试内容按钮" });
|
|
const box = await contentButton.boundingBox();
|
|
expect(box).not.toBeNull();
|
|
|
|
await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2);
|
|
await page.mouse.down();
|
|
await expect
|
|
.poll(async () => contentButton.evaluate((element) => element.matches(":active")))
|
|
.toBe(true);
|
|
await expect(contentButton).toHaveCSS("outline-style", "none");
|
|
await page.mouse.up();
|
|
});
|
|
|
|
async function prepareWorkbench(page: Page) {
|
|
await page.route("**/runtime-config.js", async (route) => {
|
|
await route.fulfill({
|
|
contentType: "application/javascript",
|
|
body: `globalThis.__TJWATER_CONFIG__ = {
|
|
TJWATER_AUTH_MODE: "disabled",
|
|
TJWATER_MAPBOX_ACCESS_TOKEN: "",
|
|
TJWATER_MAP_URL: "https://button-system.invalid/geoserver",
|
|
TJWATER_GEOSERVER_WORKSPACE: "tjwater_next",
|
|
TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid",
|
|
TJWATER_AGENT_API_BASE_URL: "http://127.0.0.1:8787",
|
|
TJWATER_ENABLE_DEV_PANEL: "false",
|
|
TJWATER_ENABLE_MSW: "false"
|
|
};`
|
|
});
|
|
});
|
|
await page.route("https://button-system.invalid/**", async (route) => {
|
|
await route.fulfill({ status: 204, body: "" });
|
|
});
|
|
await page.route("https://demotiles.maplibre.org/**", async (route) => {
|
|
await route.fulfill({ status: 204, body: "" });
|
|
});
|
|
await page.route("**/*.riv", async (route) => {
|
|
await route.abort();
|
|
});
|
|
await mockScadaApi(page);
|
|
}
|