feat: refine workbench visuals and map controls

Unify the Agent history extension with the header acrylic surface, preserve the full conversation body, and consolidate shared control and status styling.

Restore map flow and SCADA controller behavior, remove obsolete rendering paths, and extend regression coverage. Button press coverage now releases outside the target so state assertions cannot accidentally toggle the control.
This commit is contained in:
2026-07-28 16:39:36 +08:00
parent ade88b4fbf
commit f4318a9b9c
96 changed files with 5480 additions and 1875 deletions
+414 -37
View File
@@ -1,31 +1,260 @@
import { expect, test, type Locator } from "playwright/test";
import { expect, test, type Locator } from "@playwright/test";
test("agent header reveals the shell acrylic without nesting another filter", async ({ page }) => {
type BoundingBox = {
x: number;
y: number;
width: number;
height: number;
};
async function waitForAnimations(locator: Locator) {
await locator.evaluate(async (element) => {
const animations = element
.getAnimations({ subtree: true })
.filter((animation) => animation.effect?.getTiming().iterations !== Infinity);
await Promise.all(
animations.map((animation) => animation.finished.catch(() => undefined))
);
});
}
test("desktop Agent layers floating controls inside one acrylic panel", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.waitForTimeout(250);
const topBar = page.locator("header.acrylic-navigation");
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]');
const agentHeader = agentPanel.locator(".agent-panel-header");
const agentHeader = agentPanel.locator(".agent-panel-integrated-header");
const agentConversation = agentPanel.locator(".agent-panel-conversation-canvas");
const agentConversationScroll = agentPanel.locator(".agent-conversation-scroll");
const agentComposer = agentPanel.locator(".agent-panel-composer");
await expect(topBar).toBeVisible();
await expect(agentPanel).toBeVisible();
await expect(agentHeader).toBeVisible();
await expect(agentComposer).toBeVisible();
await waitForAnimations(agentPanel);
await expectAcrylicSurface(topBar);
await expectAcrylicSurface(agentPanel);
await expectTransparentAcrylicContext(agentHeader);
await expectLightweightContextSurfaces(agentPanel.locator(".agent-panel-conversation"));
await expectSolidInternalSurfaces(agentPanel.locator(".agent-panel-band"));
await expectAcrylicSurface(agentPanel, ".agent-panel-composer");
await expectIntegratedHeader(agentHeader);
await expectContextualAcrylicSurface(agentComposer, {
backdropFilter: "blur(",
maximumAlpha: 0.9,
minimumAlpha: 0.84
});
await expectLightweightMaterialContext(agentConversation);
await expectUnfilteredInternalSurfaces(agentComposer.locator(".agent-panel-control"));
await expect(agentComposer).toHaveCSS("background-color", "rgba(255, 255, 255, 0.86)");
await expect(agentComposer).toHaveCSS("border-radius", "16px");
await expect(agentComposer).toHaveCSS("border-top-width", "0px");
await expect(
agentPanel.getByText("直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。")
).toHaveCSS("color", "rgb(51, 65, 85)");
await expect(agentPanel.getByText("水力瓶颈诊断")).toHaveCSS("color", "rgb(30, 41, 59)");
await expect(agentPanel.getByText("水力瓶颈诊断")).toHaveCSS("font-size", "14px");
const panelBox = await agentPanel.boundingBox();
const conversationBox = await agentConversation.boundingBox();
const scrollBox = await agentConversationScroll.boundingBox();
const composerBox = await agentComposer.boundingBox();
expect(panelBox).not.toBeNull();
expect(conversationBox).not.toBeNull();
expect(scrollBox).not.toBeNull();
expect(composerBox).not.toBeNull();
expect(
Math.abs(conversationBox!.y + conversationBox!.height - (panelBox!.y + panelBox!.height - 4))
).toBeLessThanOrEqual(1);
expect(
Math.abs(scrollBox!.y + scrollBox!.height - (conversationBox!.y + conversationBox!.height))
).toBeLessThanOrEqual(1);
expect(composerBox!.y).toBeLessThan(conversationBox!.y + conversationBox!.height);
expect(composerBox!.x - panelBox!.x).toBeGreaterThanOrEqual(21);
expect(
Math.abs(
composerBox!.x -
panelBox!.x -
(panelBox!.x + panelBox!.width - composerBox!.x - composerBox!.width)
)
).toBeLessThanOrEqual(1);
await expect(agentConversationScroll).toHaveCSS("scrollbar-gutter", "stable both-edges");
const beforeFocus = await getFloatingSurfaceStyles(agentHeader, agentComposer);
await page.getByPlaceholder("输入调度问题,Agent 将通过后端会话流式响应").focus();
await expect
.poll(() => getFloatingSurfaceStyles(agentHeader, agentComposer))
.toEqual(beforeFocus);
});
async function expectAcrylicSurface(shell: Locator) {
const composition = await shell.evaluate((element) => {
async function expectIntegratedHeader(header: Locator) {
const composition = await header.evaluate((element) => {
const style = getComputedStyle(element);
return {
backdropFilter: style.backdropFilter,
backgroundColor: style.backgroundColor,
borderTopWidth: style.borderTopWidth,
borderRadius: style.borderRadius
};
});
expect(composition.backdropFilter).toBe("none");
expect(getAlpha(composition.backgroundColor)).toBe(0);
expect(composition.borderTopWidth).toBe("0px");
expect(composition.borderRadius).toBe("0px");
}
test("desktop Agent header expands into one attached acrylic history surface", async ({
page
}) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]');
const agentHeader = agentPanel.locator(".agent-panel-integrated-header");
const agentConversation = agentPanel.locator(".agent-panel-conversation-canvas");
const historyButton = agentPanel.getByRole("button", {
name: "打开 Agent 历史记录"
});
const headerBefore = await agentHeader.boundingBox();
const conversationBefore = await agentConversation.boundingBox();
const historyButtonPresentationBefore = await getSurfacePresentation(historyButton);
await historyButton.click();
const history = agentPanel.getByRole("region", { name: "Agent 历史记录" });
await expect(history).toBeVisible();
await waitForAnimations(history);
await expect(historyButton).toHaveAttribute("aria-expanded", "true");
await page.mouse.move(720, 320);
await expectAttachedHistoryExtension(history);
await expectTransparentExpandedHeader(agentHeader, agentPanel);
await expect
.poll(() => getSurfacePresentation(historyButton))
.toEqual(historyButtonPresentationBefore);
const headerBox = await agentHeader.boundingBox();
const agentPanelBox = await agentPanel.boundingBox();
const historyBox = await history.boundingBox();
const conversationAfter = await agentConversation.boundingBox();
expect(headerBefore).not.toBeNull();
expect(headerBox).not.toBeNull();
expect(agentPanelBox).not.toBeNull();
expect(historyBox).not.toBeNull();
expect(conversationBefore).not.toBeNull();
expect(conversationAfter).not.toBeNull();
expectRoundedValue(headerBox!.y, headerBefore!.y);
expect(headerBox!.height).toBeGreaterThan(headerBefore!.height);
expectRoundedValue(historyBox!.y, headerBefore!.y + headerBefore!.height);
expectRoundedValue(historyBox!.x, headerBox!.x + 1);
expectRoundedValue(historyBox!.width, headerBox!.width - 2);
expect(headerBox!.width).toBeLessThanOrEqual(agentPanelBox!.width);
expectRoundedBox(conversationAfter!, conversationBefore!);
await history.getByRole("button", { name: "重命名对话" }).first().click();
const renameInput = history.locator("input").first();
const editingItem = renameInput.locator(
"xpath=ancestor::*[contains(@class, 'agent-history-item')][1]"
);
await expect(renameInput).toBeFocused();
await expect(renameInput).toHaveCSS("box-shadow", "none");
await expect(renameInput).toHaveCSS("border-color", "rgb(203, 213, 225)");
await expect(editingItem).toHaveCSS("border-color", "rgba(0, 0, 0, 0)");
await history.getByRole("button", { name: "取消重命名" }).click();
await page.keyboard.press("Escape");
await expect(history).toBeHidden();
await expect(historyButton).toBeFocused();
await expect(historyButton).toHaveAttribute("aria-expanded", "false");
await historyButton.click();
await expect(history).toBeVisible();
await page.mouse.click(720, 320);
await expect(history).toBeHidden();
});
test("mobile Agent mirrors the desktop floating history surface", async ({
page
}) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "打开 Agent 面板" }).click();
const agentPanel = page
.locator('aside[aria-label="Agent 命令面板"]')
.filter({ visible: true });
await expect(agentPanel).toBeVisible();
await waitForAnimations(agentPanel);
const mobileSheet = page.getByRole("region", { name: "Agent 工作台抽屉" });
const sheetHandle = mobileSheet.locator(".mobile-workbench-sheet-handle");
const agentHeader = agentPanel.locator(".agent-panel-header");
const agentConversation = agentPanel.getByRole("log");
const historyButton = agentPanel.getByRole("button", {
name: "打开 Agent 历史记录"
});
const headerBefore = await agentHeader.boundingBox();
const conversationBefore = await agentConversation.boundingBox();
const historyButtonPresentationBefore = await getSurfacePresentation(historyButton);
await historyButton.click();
const history = agentPanel.getByRole("region", { name: "Agent 历史记录" });
await expect(history).toBeVisible();
await waitForAnimations(history);
await page.mouse.move(10, 100);
await expectAttachedHistoryExtension(history);
await expectTransparentExpandedHeader(agentHeader, mobileSheet);
await expect
.poll(() => getSurfacePresentation(historyButton))
.toEqual(historyButtonPresentationBefore);
await expect(sheetHandle).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
const panelBox = await agentPanel.boundingBox();
const headerAfter = await agentHeader.boundingBox();
const historyBox = await history.boundingBox();
const conversationAfter = await agentConversation.boundingBox();
expect(panelBox).not.toBeNull();
expect(headerBefore).not.toBeNull();
expect(headerAfter).not.toBeNull();
expect(historyBox).not.toBeNull();
expect(conversationBefore).not.toBeNull();
expect(conversationAfter).not.toBeNull();
expectRoundedValue(headerAfter!.y, headerBefore!.y);
expectRoundedValue(headerAfter!.x, panelBox!.x);
expectRoundedValue(headerAfter!.y, panelBox!.y - 28);
expectRoundedValue(headerAfter!.width, panelBox!.width);
expect(headerAfter!.height).toBeGreaterThan(headerBefore!.height);
expectRoundedValue(historyBox!.y, headerBefore!.y + headerBefore!.height);
expectRoundedValue(historyBox!.width, headerAfter!.width - 2);
expect(headerAfter!.height).toBeLessThan(panelBox!.height);
expectRoundedBox(conversationAfter!, conversationBefore!);
await expect(agentConversation).toHaveClass(/agent-panel-conversation-canvas/);
await expect(agentPanel.locator(".agent-panel-composer")).toBeVisible();
await page.keyboard.press("Escape");
await expect(history).toBeHidden();
await expect(historyButton).toBeFocused();
});
test("Agent floating acrylic respects forced color mode", async ({ page }) => {
await page.emulateMedia({ forcedColors: "active" });
await page.goto("/", { waitUntil: "domcontentloaded" });
const composer = page.locator(".agent-panel-composer");
await expect(composer).toBeVisible();
await expect(composer).toHaveCSS("backdrop-filter", "none");
await expect(composer).toHaveCSS("background-image", "none");
await expect(composer).toHaveCSS("box-shadow", "none");
});
async function expectAcrylicSurface(shell: Locator, allowedFilteredDescendant?: string) {
const composition = await shell.evaluate((element, allowedSelector) => {
const style = getComputedStyle(element);
const filteredDescendants: string[] = [];
for (const descendant of element.querySelectorAll("*")) {
if (getComputedStyle(descendant).backdropFilter !== "none") {
if (
getComputedStyle(descendant).backdropFilter !== "none" &&
!(allowedSelector && descendant.matches(allowedSelector))
) {
filteredDescendants.push(descendant.className);
}
}
@@ -37,7 +266,7 @@ async function expectAcrylicSurface(shell: Locator) {
transform: style.transform,
willChange: style.willChange
};
});
}, allowedFilteredDescendant);
expect(composition.backdropFilter).toContain("blur(");
expect(getAlpha(composition.backgroundColor)).toBeLessThan(1);
@@ -46,50 +275,148 @@ async function expectAcrylicSurface(shell: Locator) {
expect(composition.willChange).toBe("auto");
}
async function expectContextualAcrylicSurface(
context: Locator,
{
backdropFilter = "none",
maximumAlpha = 0.7,
minimumAlpha = 0.35
}: {
backdropFilter?: string;
maximumAlpha?: number;
minimumAlpha?: number;
} = {}
) {
const composition = await context.evaluate((element) => {
const style = getComputedStyle(element);
return {
backdropFilter: style.backdropFilter,
backgroundColor: style.backgroundColor,
backgroundImage: style.backgroundImage,
boxShadow: style.boxShadow
};
});
if (backdropFilter === "none") {
expect(composition.backdropFilter).toBe("none");
} else {
expect(composition.backdropFilter).toContain(backdropFilter);
}
expect(getAlpha(composition.backgroundColor)).toBeGreaterThan(minimumAlpha);
expect(getAlpha(composition.backgroundColor)).toBeLessThan(maximumAlpha);
expect(composition.backgroundImage).not.toBe("none");
expect(composition.boxShadow).not.toBe("none");
}
async function expectAttachedHistoryExtension(history: Locator) {
const presentation = await history.evaluate((element) => {
const style = getComputedStyle(element);
return {
backdropFilter: style.backdropFilter,
backgroundColor: style.backgroundColor,
backgroundImage: style.backgroundImage,
borderBottomLeftRadius: style.borderBottomLeftRadius,
borderBottomRightRadius: style.borderBottomRightRadius,
borderBottomWidth: style.borderBottomWidth,
borderLeftWidth: style.borderLeftWidth,
borderRightWidth: style.borderRightWidth,
borderTopWidth: style.borderTopWidth,
borderTopLeftRadius: style.borderTopLeftRadius,
borderTopRightRadius: style.borderTopRightRadius,
boxShadow: style.boxShadow
};
});
expect(presentation.backdropFilter).toContain("blur(");
expect(getAlpha(presentation.backgroundColor)).toBeGreaterThanOrEqual(0.78);
expect(getAlpha(presentation.backgroundColor)).toBeLessThanOrEqual(0.9);
expect(presentation.backgroundImage).not.toBe("none");
expect(presentation.borderBottomWidth).toBe("0px");
expect(presentation.borderLeftWidth).toBe("0px");
expect(presentation.borderRightWidth).toBe("0px");
expect(presentation.borderTopWidth).toBe("0px");
expect(presentation.borderTopLeftRadius).toBe("0px");
expect(presentation.borderTopRightRadius).toBe("0px");
expect(presentation.borderBottomLeftRadius).toBe("16px");
expect(presentation.borderBottomRightRadius).toBe("16px");
expect(presentation.boxShadow).toBe("none");
}
async function expectTransparentExpandedHeader(header: Locator, agentSurface: Locator) {
const [presentation, agentOutlineColor] = await Promise.all([
header.evaluate((element) => {
const style = getComputedStyle(element);
return {
backgroundColor: style.backgroundColor,
borderBottomColor: style.borderBottomColor,
borderBottomLeftRadius: style.borderBottomLeftRadius,
borderBottomRightRadius: style.borderBottomRightRadius,
borderBottomWidth: style.borderBottomWidth,
borderLeftWidth: style.borderLeftWidth,
borderRightWidth: style.borderRightWidth,
borderTopWidth: style.borderTopWidth,
boxShadow: style.boxShadow
};
}),
agentSurface.evaluate((element) =>
getComputedStyle(element).getPropertyValue("--acrylic-outline").trim()
)
]);
expect(getAlpha(presentation.backgroundColor)).toBe(0);
expect(presentation.borderBottomColor).toBe(agentOutlineColor);
expect(presentation.borderBottomLeftRadius).toBe("16px");
expect(presentation.borderBottomRightRadius).toBe("16px");
expect(presentation.borderBottomWidth).toBe("1px");
expect(presentation.borderLeftWidth).toBe("1px");
expect(presentation.borderRightWidth).toBe("1px");
expect(presentation.borderTopWidth).toBe("0px");
expect(presentation.boxShadow).not.toBe("none");
}
async function getSurfacePresentation(surface: Locator) {
return surface.evaluate((element) => {
const style = getComputedStyle(element);
return {
backdropFilter: style.backdropFilter,
backgroundColor: style.backgroundColor,
backgroundImage: style.backgroundImage,
borderColor: style.borderColor,
borderRadius: style.borderRadius,
boxShadow: style.boxShadow,
color: style.color
};
});
}
function getAlpha(color: string) {
const match = color.match(/^rgba\([^,]+,[^,]+,[^,]+,\s*([\d.]+)\)$/);
return match ? Number(match[1]) : 1;
}
async function expectTransparentAcrylicContext(header: Locator) {
const style = await header.evaluate((element) => {
async function expectLightweightMaterialContext(context: Locator) {
const style = await context.evaluate((element) => {
const computedStyle = getComputedStyle(element);
return {
backdropFilter: computedStyle.backdropFilter,
backgroundColor: computedStyle.backgroundColor,
className: element.className,
webkitBackdropFilter: computedStyle.getPropertyValue("-webkit-backdrop-filter")
};
});
expect(style.backdropFilter).toBe("none");
expect(style.backgroundColor).toBe("rgba(0, 0, 0, 0)");
expect(getAlpha(style.backgroundColor)).toBeGreaterThan(0);
expect(getAlpha(style.backgroundColor)).toBeLessThanOrEqual(0.3);
expect(["", "none"]).toContain(style.webkitBackdropFilter);
expect(String(style.className)).not.toContain("acrylic-");
}
async function expectLightweightContextSurfaces(surfaces: Locator) {
const surfaceStyles = await surfaces.evaluateAll((elements) =>
elements.map((element) => {
const style = getComputedStyle(element);
return {
backdropFilter: style.backdropFilter,
backgroundColor: style.backgroundColor
};
})
);
expect(surfaceStyles.length).toBeGreaterThan(0);
for (const style of surfaceStyles) {
expect(style.backdropFilter).toBe("none");
expect(getAlpha(style.backgroundColor)).toBeLessThanOrEqual(0.12);
}
}
async function expectSolidInternalSurfaces(surfaces: Locator) {
async function expectUnfilteredInternalSurfaces(surfaces: Locator) {
const surfaceStyles = await surfaces.evaluateAll((elements) =>
elements.map((element) => {
const style = getComputedStyle(element);
@@ -109,3 +436,53 @@ async function expectSolidInternalSurfaces(surfaces: Locator) {
expect(String(style.className)).not.toContain("acrylic-");
}
}
function expectRoundedBox(actual: BoundingBox, expected: BoundingBox) {
expect(Math.round(actual.x)).toBe(Math.round(expected.x));
expect(Math.round(actual.y)).toBe(Math.round(expected.y));
expect(Math.round(actual.width)).toBe(Math.round(expected.width));
expect(Math.round(actual.height)).toBe(Math.round(expected.height));
}
function expectRoundedValue(actual: number, expected: number) {
expect(Math.abs(Math.round(actual) - Math.round(expected))).toBeLessThanOrEqual(1);
}
async function getFloatingSurfaceStyles(header: Locator, composer: Locator) {
const control = composer.locator(".agent-panel-control");
const [headerStyles, composerStyles, controlStyles] = await Promise.all([
header.evaluate((element) => {
const style = getComputedStyle(element);
return {
shadow: style.boxShadow,
transform: style.transform
};
}),
composer.evaluate((element) => {
const style = getComputedStyle(element);
return {
shadow: style.boxShadow,
transform: style.transform
};
}),
control.evaluate((element) => {
const style = getComputedStyle(element);
return {
borderColor: style.borderColor,
boxShadow: style.boxShadow
};
})
]);
return {
composerShadow: composerStyles.shadow,
composerTransform: composerStyles.transform,
controlBorderColor: controlStyles.borderColor,
controlBoxShadow: controlStyles.boxShadow,
headerShadow: headerStyles.shadow,
headerTransform: headerStyles.transform
};
}
+21 -13
View File
@@ -1,8 +1,6 @@
import { expect, test } from "playwright/test";
import { expect, test } from "@playwright/test";
test("Agent panel resizes by drag without exceeding the 620px workspace limit", async ({
page
}) => {
test("Agent panel resizes within its responsive 720px workspace limit", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
const panel = page.locator('aside[aria-label="Agent 命令面板"]');
@@ -18,15 +16,15 @@ test("Agent panel resizes by drag without exceeding the 620px workspace limit",
await page.mouse.move(1_200, handleBox!.y + handleBox!.height / 2, { steps: 10 });
await page.mouse.up();
await expect.poll(async () => (await panel.boundingBox())?.width).toBe(620);
await expect.poll(async () => (await panel.boundingBox())?.width).toBe(676);
await resizeHandle.focus();
await page.keyboard.press("Home");
await expect.poll(async () => (await panel.boundingBox())?.width).toBe(460);
await expect.poll(async () => (await panel.boundingBox())?.width).toBe(500);
await page.keyboard.press("ArrowRight");
await expect.poll(async () => (await panel.boundingBox())?.width).toBe(476);
await expect.poll(async () => (await panel.boundingBox())?.width).toBe(516);
await page.keyboard.press("End");
await expect.poll(async () => (await panel.boundingBox())?.width).toBe(620);
await expect.poll(async () => (await panel.boundingBox())?.width).toBe(676);
});
test("collapsed Agent rail is compact and expands as one clear action", async ({ page }) => {
@@ -61,21 +59,31 @@ test.describe("mobile touch layout", () => {
const buttonBox = await openButton.boundingBox();
expect(buttonBox).not.toBeNull();
await page.touchscreen.tap(buttonBox!.x + buttonBox!.width / 2, buttonBox!.y + buttonBox!.height / 2);
await page.touchscreen.tap(
buttonBox!.x + buttonBox!.width / 2,
buttonBox!.y + buttonBox!.height / 2
);
await expect(page.locator('aside[aria-label="Agent 命令面板"]').filter({ visible: true })).toBeVisible();
await expect(
page.locator('aside[aria-label="Agent 命令面板"]').filter({ visible: true })
).toBeVisible();
await expect(page.getByRole("button", { name: "关闭 Agent 面板" })).toBeVisible();
});
});
test("mobile alert summary opens the Agent conversation panel", async ({ page }) => {
// Temporarily disabled: this regression flow would start a real Agent run.
// Agent analysis must be explicitly triggered by a user, not by automated tests.
test.skip("mobile alert summary opens the Agent conversation panel", async ({ page }) => {
await page.clock.setFixedTime(new Date("2026-07-21T10:40:00+08:00"));
await page.setViewportSize({ width: 375, height: 812 });
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /查看异常处置面板/ }).click();
await page.getByRole("button", { name: "工况汇总" }).click();
// Agent execution must remain an explicit user action.
// await page.getByRole("button", { name: "工况汇总" }).click();
await expect(page.locator('aside[aria-label="Agent 命令面板"]').filter({ visible: true })).toBeVisible();
await expect(
page.locator('aside[aria-label="Agent 命令面板"]').filter({ visible: true })
).toBeVisible();
await expect(page.getByRole("button", { name: "关闭 Agent 面板" })).toBeVisible();
});
+153 -36
View File
@@ -1,6 +1,6 @@
import { createServer, type Server } from "node:http";
import type { AddressInfo } from "node:net";
import { expect, test } from "playwright/test";
import { expect, test } from "@playwright/test";
const STREAM_CHUNK_SIZE = 18;
const STREAM_CHUNK_DELAY_MS = 20;
@@ -276,6 +276,48 @@ test("stream completion and the plan card remain positionally stable", async ({
});
await sendButton.click();
const userMessage = page.locator(".agent-panel-user-message");
const assistantMessage = page.locator(".agent-panel-message").first();
await expect(userMessage).toHaveCSS("background-color", "rgba(224, 229, 249, 0.92)");
expect(
await userMessage.evaluate((element) => getComputedStyle(element).backgroundColor)
).not.toBe(
await assistantMessage.evaluate((element) => getComputedStyle(element).backgroundColor)
);
await expect
.poll(() => userMessage.evaluate((element) => getComputedStyle(element).boxShadow))
.not.toBe("none");
await expect
.poll(() => assistantMessage.evaluate((element) => getComputedStyle(element).boxShadow))
.toBe(await userMessage.evaluate((element) => getComputedStyle(element).boxShadow));
const operationalBrief = page.locator(".agent-panel-operational-float");
await expect(operationalBrief).toBeVisible();
await expect(operationalBrief).toHaveCSS("position", "absolute");
await expect(operationalBrief).not.toHaveClass(/agent-panel-floating-acrylic/);
await expect(operationalBrief).toHaveCSS("background-color", "rgba(255, 255, 255, 0.86)");
await expect(operationalBrief).toHaveCSS("border-radius", "16px");
await expect(operationalBrief).toHaveCSS("background-image", "none");
await expect(operationalBrief).toHaveCSS("border-top-width", "0px");
await expect(operationalBrief).toHaveCSS(
"box-shadow",
"rgba(15, 33, 55, 0.14) 0px 12px 32px 0px, rgba(15, 33, 55, 0.08) 0px 3px 10px 0px"
);
await expect
.poll(() => operationalBrief.evaluate((element) => getComputedStyle(element).boxShadow))
.not.toContain("inset");
await expect
.poll(() => operationalBrief.evaluate((element) => getComputedStyle(element).backdropFilter))
.toContain("blur(");
const operationalBriefBox = await operationalBrief.boundingBox();
const conversationBox = await page.locator(".agent-panel-conversation-canvas").boundingBox();
expect(operationalBriefBox).not.toBeNull();
expect(conversationBox).not.toBeNull();
expect(operationalBriefBox!.y).toBeLessThan(conversationBox!.y + conversationBox!.height);
await expect(page.locator(".agent-panel-conversation-content-has-brief")).toHaveCSS(
"padding-top",
"208px"
);
const liveProgress = page.getByRole("list", { name: "最近执行进度" });
await expect(liveProgress.locator("li")).toHaveCount(3);
await expect(liveProgress.getByText("正在分析运行上下文", { exact: true })).toBeVisible();
@@ -393,9 +435,19 @@ test("stream completion and the plan card remain positionally stable", async ({
.filter({ visible: true })
.first();
await expect(progressToggle).toBeEnabled();
await expect(progressToggle).toHaveAttribute("data-state", "closed");
await expect
.poll(async () => (await progressToggle.boundingBox())?.height ?? 0)
.toBeGreaterThanOrEqual(40);
await progressToggle.click();
await expect(progressToggle).toHaveAttribute("data-state", "open");
const expandedLiveProgress = page.getByRole("list", { name: "全部执行进度" });
await expect(expandedLiveProgress.locator("li")).toHaveCount(4);
await expect(expandedLiveProgress.locator(".status-badge")).toHaveCount(0);
await expect(expandedLiveProgress.locator('[data-progress-status="running"]').first()).toHaveCSS(
"color",
"oklch(0.546 0.245 262.881)"
);
await expect(expandedLiveProgress.getByText("正在分析运行上下文", { exact: true })).toBeVisible();
await expect(
expandedLiveProgress.getByText("正在核对降雨过程与汇水区响应关系", { exact: true })
@@ -403,34 +455,9 @@ test("stream completion and the plan card remain positionally stable", async ({
await progressToggle.click();
await expect(page.getByRole("list", { name: "最近执行进度" }).locator("li")).toHaveCount(3);
await page.evaluate(() => {
const state = window as typeof window & { __agentFailureMotionObserved?: boolean };
const startedAt = performance.now();
state.__agentFailureMotionObserved = false;
const sample = () => {
const badge = document.querySelector<HTMLElement>('[data-progress-status="error"]');
if (badge && getComputedStyle(badge).transform !== "none") {
state.__agentFailureMotionObserved = true;
}
if (!state.__agentFailureMotionObserved && performance.now() - startedAt < 1_000) {
requestAnimationFrame(sample);
}
};
requestAnimationFrame(sample);
});
triggerProgressFailure();
await expect(liveProgress.getByText("液位连续性核对失败", { exact: true })).toBeVisible();
await expect
.poll(() =>
page.evaluate(
() =>
(window as typeof window & { __agentFailureMotionObserved?: boolean })
.__agentFailureMotionObserved ?? false
)
)
.toBe(true);
await expect(page.locator('[data-progress-status="error"]')).toHaveCSS("transform", "none");
resumeStream();
await expect(
@@ -438,7 +465,7 @@ test("stream completion and the plan card remain positionally stable", async ({
).toBeVisible({
timeout: 10_000
});
await expect(progressToggle).toContainText("共 4 条");
await expect(progressToggle.locator("../..")).toContainText("步骤4/4");
await expect(page.getByRole("list", { name: "最近执行进度" })).toHaveCount(0);
await expect(
page.getByText("溢流风险判断完成", { exact: true }).filter({ visible: true })
@@ -528,24 +555,114 @@ test("stream completion and the plan card remain positionally stable", async ({
.getByRole("button", { name: /执行进度/ })
.filter({ visible: true })
.last();
const latestProgressRegion = latestProgressToggle.locator("..");
const latestProgressRegion = latestProgressToggle.locator("../..");
const latestExpandedProgress = latestProgressRegion.getByRole("list", {
name: "全部执行进度",
name: "全部执行进度"
});
await expect(latestProgressToggle).toContainText("最近 3 条");
await expect(latestProgressRegion).toContainText("最近3/3");
triggerProgressAppend();
await expect(
page.getByRole("list", { name: "最近执行进度" }).getByText("正在判断溢流风险", { exact: true })
).toBeVisible();
await latestProgressToggle.click();
await expect(latestProgressToggle).toContainText("全部 4 条");
await expect(latestProgressRegion).toContainText("全部4/4");
resumeStream();
await expect(latestProgressToggle).toContainText("全部 4 条", { timeout: 10_000 });
await expect(latestProgressRegion).toContainText("全部4/4", { timeout: 10_000 });
await expect(latestExpandedProgress).toBeVisible();
await expect(
latestExpandedProgress.getByText("溢流风险判断完成", { exact: true })
).toBeVisible();
await expect(latestExpandedProgress.getByText("溢流风险判断完成", { exact: true })).toBeVisible();
const conversationScroll = page.locator(".agent-conversation-scroll");
await conversationScroll.hover();
await page.mouse.wheel(0, -2400);
const scrollToLatestButton = page.getByRole("button", { name: "滚动到最新消息" });
await expect(scrollToLatestButton).toBeVisible();
await expect(scrollToLatestButton).toHaveCSS("background-color", "rgba(255, 255, 255, 0.86)");
await expect
.poll(() =>
scrollToLatestButton.evaluate((element) => getComputedStyle(element).backdropFilter)
)
.toContain("blur(");
await expect
.poll(() => scrollToLatestButton.evaluate((element) => getComputedStyle(element).boxShadow))
.not.toBe("none");
const readAcrylicMaterial = (element: HTMLElement) => {
const style = getComputedStyle(element);
return {
backdropFilter: style.backdropFilter,
backgroundColor: style.backgroundColor,
backgroundImage: style.backgroundImage,
boxShadow: style.boxShadow
};
};
expect(await scrollToLatestButton.evaluate(readAcrylicMaterial)).toEqual(
await page.locator(".agent-panel-composer").evaluate(readAcrylicMaterial)
);
const selectableAssistantMessage = page.getByRole("heading", { name: "诊断结果" }).first();
await selectableAssistantMessage.evaluate((element) => {
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
let textNode = walker.nextNode();
while (textNode && !textNode.textContent?.trim()) {
textNode = walker.nextNode();
}
if (!textNode?.textContent) {
throw new Error("Assistant message does not contain selectable text");
}
const range = document.createRange();
range.setStart(textNode, 0);
range.setEnd(textNode, Math.min(8, textNode.textContent.length));
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
element.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
});
const speechSelectionIconMotion = await page.evaluate(async () => {
const waitForAnimationFrame = () =>
new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
const deadline = performance.now() + 1_000;
let button: HTMLButtonElement | undefined;
while (!button && performance.now() < deadline) {
button = Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find((element) =>
element.textContent?.includes("从这里开始朗读")
);
if (!button) await waitForAnimationFrame();
}
const icon = button?.querySelector("svg");
if (!button || !icon) {
throw new Error("Speech selection action did not render with an icon");
}
const samples: Array<{ x: number; y: number }> = [];
const startedAt = performance.now();
while (performance.now() - startedAt < 220) {
const rect = icon.getBoundingClientRect();
samples.push({
x: rect.x + rect.width / 2,
y: rect.y + rect.height / 2
});
await waitForAnimationFrame();
}
const xValues = samples.map((sample) => sample.x);
const yValues = samples.map((sample) => sample.y);
return {
xSpread: Math.max(...xValues) - Math.min(...xValues),
ySpread: Math.max(...yValues) - Math.min(...yValues)
};
});
expect(speechSelectionIconMotion.xSpread).toBeLessThanOrEqual(0.1);
expect(speechSelectionIconMotion.ySpread).toBeLessThanOrEqual(0.1);
const speechSelectionButton = page.getByRole("button", { name: "从这里开始朗读" });
await expect(speechSelectionButton).toBeVisible();
await expect(speechSelectionButton).toHaveCSS("border-top-width", "0px");
expect(await speechSelectionButton.evaluate(readAcrylicMaterial)).toEqual(
await scrollToLatestButton.evaluate(readAcrylicMaterial)
);
});
function createLongMarkdownResponse() {
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "playwright/test";
import { expect, test } from "@playwright/test";
test("speech capability detection preserves the server hydration tree", async ({ page }) => {
const hydrationErrors: string[] = [];
+172
View File
@@ -0,0 +1,172 @@
import { expect, test, type Page } from "@playwright/test";
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 scenario = page.getByRole("button", { name: "切换模拟方案" });
await expect(scenario).toBeVisible();
await expect.poll(async () => (await scenario.boundingBox())?.height).toBe(32);
const compactHitArea = await scenario.evaluate(
(element) => getComputedStyle(element, "::after").inset
);
expect(compactHitArea).toBe("-4px");
await scenario.focus();
await expect
.poll(async () => scenario.evaluate((element) => getComputedStyle(element).boxShadow))
.not.toBe("none");
await scenario.hover();
await expect
.poll(async () => scenario.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("rgb(232, 241, 255)");
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("rgb(207, 226, 255)");
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("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_MAPBOX_ACCESS_TOKEN: "",
TJWATER_MAP_URL: "https://button-system.invalid/geoserver",
TJWATER_GEOSERVER_WORKSPACE: "tjwater",
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();
});
}
+57 -2
View File
@@ -1,8 +1,11 @@
import { expect, test } from "playwright/test";
import { expect, test } from "@playwright/test";
test("Dev Panel opens on desktop, owns the right workspace, and closes with Escape", async ({ page }) => {
test("Dev Panel opens as a floating desktop panel and closes with Escape", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.waitForFunction(() => Boolean(window.__waterNetworkMap));
await page.waitForFunction(() => Boolean(
window.__waterNetworkMap?.getLayer("scada-hit")
));
const trigger = page.getByRole("button", { name: "切换地图 Dev Panel" });
await expect(trigger).toBeVisible();
await expect(page.getByRole("navigation", { name: "地图工具" })).toBeVisible();
@@ -11,10 +14,25 @@ test("Dev Panel opens on desktop, owns the right workspace, and closes with Esca
const panel = page.getByTestId("map-dev-panel");
await expect(panel).toBeVisible();
await expect(panel).toHaveCSS("width", "400px");
await expect(panel).toHaveCSS("border-radius", "16px");
const panelBox = await panel.boundingBox();
expect(panelBox).not.toBeNull();
expect(panelBox!.x).toBe(1028);
expect(panelBox!.y).toBe(72);
expect(panelBox!.y + panelBox!.height).toBeLessThanOrEqual(884);
await expect(page.getByRole("navigation", { name: "地图工具" })).toBeHidden();
await expect(panel.getByRole("button", { name: "定位并高亮" })).toBeVisible();
await expect(panel.getByRole("button", { name: "重置全部" })).toBeVisible();
await panel.getByLabel("管道 ID").fill("P-9");
await panel.getByLabel("有符号流速").fill("-2.75");
await panel.getByRole("button", { name: "更新该管道方向" }).click();
await expect(panel.getByRole("status")).toContainText(
"已赋值 1 · 正向 0 · 反向 1 · 停止 0"
);
await panel.getByRole("button", { name: "开启水流" }).click();
await expect(panel.getByRole("button", { name: "关闭水流" })).toBeEnabled();
await page.keyboard.press("Escape");
await expect(panel).toBeHidden();
});
@@ -25,3 +43,40 @@ test("Dev entry is absent below the desktop breakpoint", async ({ page }) => {
await expect(page.getByRole("button", { name: "切换地图 Dev Panel" })).toBeHidden();
await expect(page.getByTestId("map-dev-panel")).toHaveCount(0);
});
test("labels remain active per business group and clear globally", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.waitForFunction(() => Boolean(
window.__waterNetworkMap?.getLayer("workbench-value-label-pipes")
));
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
const panel = page.getByTestId("map-dev-panel");
const apply = panel.getByRole("button", { name: "应用标注", exact: true });
await apply.click();
await panel.getByLabel("业务图层组").selectOption("junctions");
await apply.click();
await expect.poll(() => page.evaluate(() => ({
pipes: window.__waterNetworkMap?.getLayoutProperty(
"workbench-value-label-pipes",
"visibility"
),
junctions: window.__waterNetworkMap?.getLayoutProperty(
"workbench-value-label-junctions",
"visibility"
)
}))).toEqual({ pipes: "visible", junctions: "visible" });
await panel.getByRole("button", { name: "清除全部标注" }).click();
await expect.poll(() => page.evaluate(() => ({
pipes: window.__waterNetworkMap?.getLayoutProperty(
"workbench-value-label-pipes",
"visibility"
),
junctions: window.__waterNetworkMap?.getLayoutProperty(
"workbench-value-label-junctions",
"visibility"
)
}))).toEqual({ pipes: "none", junctions: "none" });
});
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "playwright/test";
import { expect, test } from "@playwright/test";
test("map notice is centered in the mobile viewport", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
+12 -4
View File
@@ -1,4 +1,4 @@
import { expect, test } from "playwright/test";
import { expect, test } from "@playwright/test";
test.describe("mobile workbench sheet", () => {
test.use({ viewport: { width: 390, height: 844 } });
@@ -34,6 +34,8 @@ test.describe("mobile workbench sheet", () => {
await expect(sheet).toBeVisible();
await expect(closeButton).toHaveCount(1);
await expect(closeButton).toHaveClass(/agent-panel-icon-button/);
await expect(closeButton).toHaveCSS("border-radius", "12px");
await expect.poll(async () => Math.round((await closeButton.boundingBox())?.height ?? 0)).toBe(
40
);
@@ -55,8 +57,8 @@ test.describe("mobile workbench sheet", () => {
})
)
.toEqual({
backdropFilter: "blur(24px) saturate(1.08)",
nestedFilterCount: 0,
backdropFilter: "blur(18px) saturate(1.08)",
nestedFilterCount: 1,
opacity: "1",
transform: "none"
});
@@ -113,8 +115,11 @@ test.describe("mobile workbench sheet", () => {
await page.getByRole("button", { name: "打开工况任务" }).click();
const sheet = page.getByRole("region", { name: "工况任务抽屉" });
const closeButton = page.getByRole("button", { name: "关闭工况任务" });
await expect(sheet).toBeVisible();
await page.getByRole("button", { name: "关闭工况任务" }).click();
await expect(closeButton).toHaveClass(/agent-panel-icon-button/);
await expect(closeButton).toHaveCSS("border-radius", "12px");
await closeButton.click();
expect(await sheet.count()).toBe(0);
await expect(page.getByRole("button", { name: "打开工况任务" })).toBeVisible();
});
@@ -126,6 +131,9 @@ test.describe("mobile workbench sheet", () => {
await sheet.getByRole("button", { name: /SCADA 数据诊断/ }).first().click();
await expect(sheet.getByRole("button", { name: "收起工况任务" })).toBeVisible();
const workflowTitle = sheet.getByText("SCADA 诊断流程", { exact: true });
await expect(workflowTitle).toBeVisible();
await expect(workflowTitle.locator("..").locator("svg")).toHaveCount(0);
});
test("keeps the waiting-for-reply state readable in the narrow sheet", async ({ page }) => {
@@ -1,29 +1,33 @@
import { expect, test, type Locator } from "playwright/test";
import { expect, test, type Locator } from "@playwright/test";
test("scheduled conditions keep acrylic blur outside transformed ancestors", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
const panel = page.getByRole("region", { name: "工况任务", exact: true });
await expect(panel).toBeVisible();
await page.waitForTimeout(250);
await expectAcrylicComposition(panel);
await expect(panel).toHaveCSS("width", "432px");
const timeline = panel.getByTestId("scheduled-condition-timeline");
const detailViewport = panel.locator(".scheduled-feed-detail-viewport");
const detailScroll = panel.getByTestId("scheduled-condition-detail-scroll");
const collapsedPanelBox = await panel.boundingBox();
const collapsedTimelineBox = await timeline.boundingBox();
expect(collapsedPanelBox).not.toBeNull();
expect(collapsedTimelineBox).not.toBeNull();
await panel.getByRole("button", { name: "展开工况任务" }).click();
await page.waitForTimeout(250);
await expectAcrylicComposition(panel);
await expect(panel).toHaveCSS("width", "880px");
const expandedPanelBox = await panel.boundingBox();
const expandedTimelineBox = await timeline.boundingBox();
const expandedDetailViewportBox = await detailViewport.boundingBox();
const expandedDetailScrollBox = await detailScroll.boundingBox();
expect(expandedPanelBox).not.toBeNull();
expect(expandedTimelineBox).not.toBeNull();
expect(expandedDetailViewportBox).not.toBeNull();
expect(expandedDetailScrollBox).not.toBeNull();
expect(Math.round(expandedPanelBox!.x + expandedPanelBox!.width)).toBe(
Math.round(collapsedPanelBox!.x + collapsedPanelBox!.width)
);
@@ -33,9 +37,32 @@ test("scheduled conditions keep acrylic blur outside transformed ancestors", asy
expect(Math.round(expandedTimelineBox!.x + expandedTimelineBox!.width)).toBe(
Math.round(collapsedTimelineBox!.x + collapsedTimelineBox!.width)
);
expect(expandedTimelineBox!.y + expandedTimelineBox!.height).toBeLessThanOrEqual(
expandedPanelBox!.y + expandedPanelBox!.height - 12
);
expect(Math.round(expandedDetailViewportBox!.y + expandedDetailViewportBox!.height)).toBe(
Math.round(expandedTimelineBox!.y + expandedTimelineBox!.height)
);
expect(expandedDetailScrollBox!.y).toBeGreaterThanOrEqual(
expandedDetailViewportBox!.y + 8
);
expect(expandedDetailScrollBox!.y + expandedDetailScrollBox!.height).toBeLessThanOrEqual(
expandedDetailViewportBox!.y + expandedDetailViewportBox!.height - 8
);
await expect(detailViewport).toHaveCSS("border-radius", "12px");
const timelineScroll = timeline.locator(".scheduled-feed-scroll.flex-1");
expect(await readScrollbarPresentation(detailScroll)).toEqual(
await readScrollbarPresentation(timelineScroll)
);
const futureScrollBox = await timeline.locator(".scheduled-feed-scroll").first().boundingBox();
const recentScrollBox = await timelineScroll.boundingBox();
expect(futureScrollBox).not.toBeNull();
expect(recentScrollBox).not.toBeNull();
expect(Math.round(futureScrollBox!.x + futureScrollBox!.width)).toBe(
Math.round(recentScrollBox!.x + recentScrollBox!.width)
);
await panel.getByRole("button", { name: "收起工况任务" }).click();
await page.waitForTimeout(250);
await expect(panel).toHaveCSS("width", "432px");
const restoredTimelineBox = await timeline.boundingBox();
expect(restoredTimelineBox).not.toBeNull();
@@ -82,6 +109,94 @@ test("narrow desktop keeps Agent and expanded conditions from overlapping", asyn
await expect(agentPanel).toBeVisible();
});
test("switching conditions keeps the detail viewport fully expanded", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
const panel = page.getByRole("region", { name: "工况任务", exact: true });
await panel.getByRole("button", { name: "展开工况任务" }).click();
await expect(panel).toHaveCSS("width", "880px");
const detailScroll = panel.getByTestId("scheduled-condition-detail-scroll");
const baselineHeight = await detailScroll.evaluate(
(element) => element.getBoundingClientRect().height
);
const targetCondition = panel
.getByTestId("scheduled-condition-timeline")
.getByRole("button", { name: /SCADA 数据诊断/ })
.nth(1);
const sampledHeights = await targetCondition.evaluate(async (element) => {
element.click();
const heights: number[] = [];
const startedAt = performance.now();
await new Promise<void>((resolve) => {
const sample = () => {
const detail = document.querySelector<HTMLElement>(
'[data-testid="scheduled-condition-detail-scroll"]'
);
if (detail) {
heights.push(detail.getBoundingClientRect().height);
}
if (performance.now() - startedAt >= 650) {
resolve();
return;
}
requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
});
return heights;
});
expect(sampledHeights.length).toBeGreaterThan(1);
expect(Math.min(...sampledHeights)).toBeGreaterThanOrEqual(baselineHeight - 1);
await panel
.getByTestId("scheduled-condition-timeline")
.getByRole("button", { name: /SCADA 数据诊断/ })
.first()
.click();
const workflowTitle = panel.getByText("SCADA 诊断流程", { exact: true });
await expect(workflowTitle).toBeVisible();
await expect(workflowTitle.locator("..").locator("svg")).toHaveCount(0);
});
test("keeps execution spinners without breathing badges after selecting timeline tasks", async ({
page
}) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
const panel = page.getByRole("region", { name: "工况任务", exact: true });
await panel.getByRole("button", { name: "展开工况任务" }).click();
const timeline = panel.getByTestId("scheduled-condition-timeline");
const runningCondition = timeline
.locator('button[aria-pressed]')
.filter({ hasText: "SCADA 数据诊断" })
.filter({ hasText: "执行中" })
.first();
const executingWorkOrder = timeline
.locator('button[aria-pressed]')
.filter({ hasText: "系统工单:北辰分区阀门开度调整" });
await expectRunningStatusAnimations(runningCondition);
await runningCondition.click();
await expectRunningStatusAnimations(runningCondition);
await expect(
panel
.getByText("SCADA 诊断流程", { exact: true })
.locator("..")
.locator(".status-badge .status-dot")
).toHaveCount(0);
await expectRunningStatusAnimations(executingWorkOrder);
await expect(executingWorkOrder.getByText("执行中", { exact: true })).toBeVisible();
await executingWorkOrder.click();
await expectRunningStatusAnimations(executingWorkOrder);
});
test("shows completed field work as a static waiting-for-reply state", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
@@ -93,13 +208,47 @@ test("shows completed field work as a static waiting-for-reply state", async ({
.filter({ hasText: "系统工单:泵站边界复核复令" });
await expect(awaitingReplyWorkOrder.getByText("待复令", { exact: true })).toBeVisible();
await expect(awaitingReplyWorkOrder.locator(".animate-spin")).toHaveCount(0);
await expect(awaitingReplyWorkOrder.locator(".bg-orange-50")).toBeVisible();
await expect(awaitingReplyWorkOrder.locator(".status-icon")).toHaveAttribute(
"data-status-tone",
"warning"
);
await expect(awaitingReplyWorkOrder.locator(".status-glyph")).toHaveAttribute(
"data-activity",
"static"
);
await expect.poll(() => getAnimationName(awaitingReplyWorkOrder.locator(".status-glyph"))).toBe(
"none"
);
await awaitingReplyWorkOrder.click();
await expect(panel.getByText("当前阶段:待复令", { exact: true })).toBeVisible();
await expect(
panel
.getByTestId("scheduled-condition-detail-scroll")
.locator('.status-badge[data-status-tone="warning"]')
.filter({ hasText: "待复令" })
.first()
).toHaveAttribute("data-status-tone", "warning");
});
async function readScrollbarPresentation(locator: Locator) {
return locator.evaluate((element) => {
const style = getComputedStyle(element);
const scrollbarStyle = getComputedStyle(element, "::-webkit-scrollbar");
const thumbStyle = getComputedStyle(element, "::-webkit-scrollbar-thumb");
return {
gutter: style.scrollbarGutter,
overflowY: style.overflowY,
scrollbarColor: style.scrollbarColor,
scrollbarWidth: style.scrollbarWidth,
thumbBackground: thumbStyle.backgroundColor,
thumbRadius: thumbStyle.borderRadius,
webkitWidth: scrollbarStyle.width
};
});
}
async function expectAcrylicComposition(panel: Locator) {
const composition = await panel.evaluate((element) => {
const rootStyle = getComputedStyle(element);
@@ -142,3 +291,15 @@ async function expectAcrylicComposition(panel: Locator) {
expect(composition.transform).toBe("none");
expect(composition.willChange).toBe("auto");
}
async function expectRunningStatusAnimations(row: Locator) {
const timelineGlyph = row.locator(".status-icon .status-glyph");
await expect(timelineGlyph).toHaveAttribute("data-activity", "loading");
await expect(row.locator(".status-badge .status-dot")).toHaveCount(0);
await expect.poll(() => getAnimationName(timelineGlyph)).toBe("status-spin");
}
async function getAnimationName(locator: Locator) {
return locator.evaluate((element) => getComputedStyle(element).animationName);
}
+92
View File
@@ -0,0 +1,92 @@
import { expect, test, type Locator } from "@playwright/test";
test("native scroll regions share the workbench scrollbar presentation", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
const reference = page
.getByRole("region", { name: "工况任务", exact: true })
.getByTestId("scheduled-condition-timeline")
.locator(".scheduled-feed-scroll.flex-1");
await page.evaluate(() => {
const probe = document.createElement("div");
const content = document.createElement("div");
probe.dataset.testid = "global-scrollbar-probe";
probe.style.cssText =
"position:fixed;left:8px;bottom:8px;width:40px;height:40px;overflow:auto;z-index:9999";
content.style.cssText = "width:80px;height:80px";
probe.append(content);
document.body.append(probe);
});
const probe = page.getByTestId("global-scrollbar-probe");
const presentation = await readScrollbarPresentation(reference);
expect(await readScrollbarPresentation(probe)).toEqual(presentation);
expect(presentation).toMatchObject({
height: "4px",
thumbRadius: "2px",
trackRadius: "0px",
width: "4px"
});
await reference.hover();
const referenceHoverThumb = await readScrollbarThumb(reference);
await probe.hover();
expect(await readScrollbarThumb(probe)).toBe(referenceHoverThumb);
});
test("intentional hidden-scrollbar regions remain hidden", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.evaluate(() => {
const probe = document.createElement("div");
const content = document.createElement("div");
probe.dataset.testid = "hidden-scrollbar-probe";
probe.className = "[scrollbar-width:none] [&::-webkit-scrollbar]:hidden";
probe.style.cssText = "width:40px;height:40px;overflow:auto";
content.style.cssText = "width:80px;height:80px";
probe.append(content);
document.body.append(probe);
});
const hiddenProbe = page.getByTestId("hidden-scrollbar-probe");
const presentation = await hiddenProbe.evaluate((element) => ({
display: getComputedStyle(element, "::-webkit-scrollbar").display,
width: getComputedStyle(element).scrollbarWidth
}));
expect(presentation).toEqual({
display: "none",
width: "none"
});
});
async function readScrollbarPresentation(locator: Locator) {
return locator.evaluate((element) => {
const style = getComputedStyle(element);
const scrollbar = getComputedStyle(element, "::-webkit-scrollbar");
const track = getComputedStyle(element, "::-webkit-scrollbar-track");
const thumb = getComputedStyle(element, "::-webkit-scrollbar-thumb");
const corner = getComputedStyle(element, "::-webkit-scrollbar-corner");
return {
color: style.scrollbarColor,
cornerBackground: corner.backgroundColor,
height: scrollbar.height,
thumbBackground: thumb.backgroundColor,
thumbRadius: thumb.borderRadius,
trackBackground: track.backgroundColor,
trackRadius: track.borderRadius,
width: scrollbar.width,
widthMode: style.scrollbarWidth
};
});
}
async function readScrollbarThumb(locator: Locator) {
return locator.evaluate(
(element) => getComputedStyle(element, "::-webkit-scrollbar-thumb").backgroundColor
);
}
@@ -1,4 +1,4 @@
import { expect, test } from "playwright/test";
import { expect, test } from "@playwright/test";
test("opening Agent and condition panels does not move the map camera", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
+28 -2
View File
@@ -1,4 +1,4 @@
import { expect, test } from "playwright/test";
import { expect, test } from "@playwright/test";
test("top bar uses compact controls on narrow mobile viewports", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
@@ -10,7 +10,33 @@ test("top bar uses compact controls on narrow mobile viewports", async ({ page }
);
await expect(page.getByRole("button", { name: "切换模拟方案" })).toBeHidden();
await expect(page.getByRole("button", { name: "切换地图 Dev Panel" })).toBeHidden();
await expect(page.getByRole("button", { name: /查看异常处置面板/ })).toBeVisible();
const alertButton = page.getByRole("button", { name: /查看异常处置面板/ });
const userButton = page.getByRole("button", { name: "打开用户菜单" });
await expect(alertButton).toBeVisible();
await expect(userButton).toBeVisible();
const [alertBox, userBox] = await Promise.all([
alertButton.boundingBox(),
userButton.boundingBox()
]);
expect(alertBox).not.toBeNull();
expect(userBox).not.toBeNull();
expect(alertBox?.width).toBe(40);
expect(alertBox?.height).toBe(40);
expect(userBox?.width).toBe(alertBox?.width);
expect(userBox?.height).toBe(alertBox?.height);
const [alertStyles, userStyles] = await Promise.all([
alertButton.evaluate((element) => {
const styles = getComputedStyle(element);
return { backgroundColor: styles.backgroundColor, borderRadius: styles.borderRadius };
}),
userButton.evaluate((element) => {
const styles = getComputedStyle(element);
return { backgroundColor: styles.backgroundColor, borderRadius: styles.borderRadius };
})
]);
expect(alertStyles).toEqual(userStyles);
});
test("top bar keeps text horizontal at the desktop breakpoint", async ({ page }) => {
+28 -13
View File
@@ -1,4 +1,4 @@
import { expect, test, type Locator, type Page } from "playwright/test";
import { expect, test, type Locator, type Page } from "@playwright/test";
const EMPTY_RASTER_TILE = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
@@ -19,7 +19,8 @@ test.describe("neutral blue mist workbench", () => {
await expectDesktopFloatingGeometry(page);
await expectAcrylicAlphas(page, {
navigation: 0.64,
panel: 0.56,
agentPanel: 0.82,
conditionPanel: 0.56,
control: 0.74
});
await expectLoadedChineseFont(page);
@@ -39,7 +40,8 @@ test.describe("neutral blue mist workbench", () => {
await expectDesktopFloatingGeometry(page);
await expectAcrylicAlphas(page, {
navigation: 0.8,
panel: 0.72,
agentPanel: 0.88,
conditionPanel: 0.72,
control: 0.86
});
await expect(page).toHaveScreenshot("workbench-desktop-satellite.png", {
@@ -58,12 +60,13 @@ test.describe("neutral blue mist workbench", () => {
await resizeHandle.focus();
await page.keyboard.press("End");
await expect(agentPanel).toHaveCSS("width", "620px");
await expectMapCorridor(agentPanel, conditionPanel, 768);
await expect(agentPanel).toHaveCSS("width", "720px");
await expectMapCorridor(agentPanel, conditionPanel, 680);
await conditionPanel.getByRole("button", { name: "展开工况任务" }).click();
await expect(conditionPanel).toHaveCSS("width", "960px");
await expectMapCorridor(agentPanel, conditionPanel, 260);
await expect(agentPanel).toHaveCSS("width", "628px");
await expectMapCorridor(agentPanel, conditionPanel, 256);
await expect(page).toHaveScreenshot("workbench-desktop-wide-max-agent.png", {
animations: "disabled",
maxDiffPixelRatio: 0.01
@@ -185,11 +188,16 @@ async function selectBasemap(page: Page, label: "浅色" | "影像") {
async function expectDesktopFloatingGeometry(page: Page) {
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]');
const agentHeader = agentPanel.locator(".agent-panel-integrated-header");
const agentComposer = agentPanel.locator(".agent-panel-composer");
const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true });
await expect(agentPanel).toHaveCSS("width", "460px");
await expect(agentPanel).toHaveCSS("width", "500px");
await expect(conditionPanel).toHaveCSS("width", "432px");
await expect(agentPanel).toHaveCSS("border-radius", "16px");
await expect(agentHeader).toHaveCSS("border-radius", "0px");
await expect(agentComposer).toHaveCSS("border-radius", "16px");
await expect(agentComposer).toHaveCSS("border-top-width", "0px");
await expect(conditionPanel).toHaveCSS("border-radius", "16px");
const agentBox = await agentPanel.boundingBox();
@@ -212,9 +220,7 @@ async function expectMapCorridor(
const conditionBox = await conditionPanel.boundingBox();
expect(agentBox).not.toBeNull();
expect(conditionBox).not.toBeNull();
expect(conditionBox!.x - (agentBox!.x + agentBox!.width)).toBeGreaterThanOrEqual(
minimumWidth
);
expect(conditionBox!.x - (agentBox!.x + agentBox!.width)).toBeGreaterThanOrEqual(minimumWidth);
}
async function expectLoadedChineseFont(page: Page) {
@@ -227,12 +233,20 @@ async function expectLoadedChineseFont(page: Page) {
async function expectAcrylicAlphas(
page: Page,
expected: { navigation: number; panel: number; control: number }
expected: {
navigation: number;
agentPanel: number;
conditionPanel: number;
control: number;
}
) {
const alphas = await page.evaluate(() => {
return {
navigation: alphaOf(document.querySelector(".acrylic-navigation")),
panel: alphaOf(document.querySelector(".acrylic-panel")),
agentPanel: alphaOf(
document.querySelector('aside[aria-label="Agent 命令面板"]')
),
conditionPanel: alphaOf(document.querySelector(".scheduled-feed-panel-shell")),
control: alphaOf(document.querySelector(".acrylic-control"))
};
@@ -247,7 +261,8 @@ async function expectAcrylicAlphas(
});
expect(alphas.navigation).toBeCloseTo(expected.navigation, 2);
expect(alphas.panel).toBeCloseTo(expected.panel, 2);
expect(alphas.agentPanel).toBeCloseTo(expected.agentPanel, 2);
expect(alphas.conditionPanel).toBeCloseTo(expected.conditionPanel, 2);
expect(alphas.control).toBeCloseTo(expected.control, 2);
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 470 KiB

After

Width:  |  Height:  |  Size: 499 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 429 KiB

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 853 KiB

After

Width:  |  Height:  |  Size: 828 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 71 KiB