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.
489 lines
19 KiB
TypeScript
489 lines
19 KiB
TypeScript
import { expect, test, type Locator } from "@playwright/test";
|
|
|
|
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" });
|
|
|
|
const topBar = page.locator("header.acrylic-navigation");
|
|
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 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, ".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 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" &&
|
|
!(allowedSelector && descendant.matches(allowedSelector))
|
|
) {
|
|
filteredDescendants.push(descendant.className);
|
|
}
|
|
}
|
|
|
|
return {
|
|
backdropFilter: style.backdropFilter,
|
|
backgroundColor: style.backgroundColor,
|
|
filteredDescendants,
|
|
transform: style.transform,
|
|
willChange: style.willChange
|
|
};
|
|
}, allowedFilteredDescendant);
|
|
|
|
expect(composition.backdropFilter).toContain("blur(");
|
|
expect(getAlpha(composition.backgroundColor)).toBeLessThan(1);
|
|
expect(composition.filteredDescendants).toEqual([]);
|
|
expect(composition.transform).toBe("none");
|
|
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 expectLightweightMaterialContext(context: Locator) {
|
|
const style = await context.evaluate((element) => {
|
|
const computedStyle = getComputedStyle(element);
|
|
|
|
return {
|
|
backdropFilter: computedStyle.backdropFilter,
|
|
backgroundColor: computedStyle.backgroundColor,
|
|
webkitBackdropFilter: computedStyle.getPropertyValue("-webkit-backdrop-filter")
|
|
};
|
|
});
|
|
|
|
expect(style.backdropFilter).toBe("none");
|
|
expect(getAlpha(style.backgroundColor)).toBeGreaterThan(0);
|
|
expect(getAlpha(style.backgroundColor)).toBeLessThanOrEqual(0.3);
|
|
expect(["", "none"]).toContain(style.webkitBackdropFilter);
|
|
}
|
|
|
|
async function expectUnfilteredInternalSurfaces(surfaces: Locator) {
|
|
const surfaceStyles = await surfaces.evaluateAll((elements) =>
|
|
elements.map((element) => {
|
|
const style = getComputedStyle(element);
|
|
|
|
return {
|
|
backdropFilter: style.backdropFilter,
|
|
className: element.className,
|
|
webkitBackdropFilter: style.getPropertyValue("-webkit-backdrop-filter")
|
|
};
|
|
})
|
|
);
|
|
|
|
expect(surfaceStyles.length).toBeGreaterThan(0);
|
|
for (const style of surfaceStyles) {
|
|
expect(style.backdropFilter).toBe("none");
|
|
expect(["", "none"]).toContain(style.webkitBackdropFilter);
|
|
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
|
|
};
|
|
}
|