Files

567 lines
22 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);
const agentPanelFrameBefore = await expectAcrylicOutline(agentPanel);
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 expectUnifiedHistoryStructure(history);
await expect.poll(() => getAcrylicFrame(agentPanel)).toEqual(agentPanelFrameBefore);
await expect
.poll(() => getSurfacePresentation(historyButton))
.toEqual(historyButtonPresentationBefore);
const sessionTrigger = history.locator(".agent-history-session-trigger").first();
const sessionTriggerBox = await sessionTrigger.boundingBox();
const historyHeadingBox = await history.getByText("历史记录", { exact: true }).boundingBox();
expect(sessionTriggerBox).not.toBeNull();
expect(historyHeadingBox).not.toBeNull();
await page.mouse.move(
sessionTriggerBox!.x + sessionTriggerBox!.width / 2,
sessionTriggerBox!.y + sessionTriggerBox!.height / 2
);
await page.mouse.down();
await expect(sessionTrigger).toHaveCSS("outline-style", "none");
await page.mouse.move(
historyHeadingBox!.x + historyHeadingBox!.width / 2,
historyHeadingBox!.y + historyHeadingBox!.height / 2
);
await page.mouse.up();
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(headerBox!.x, agentPanelBox!.x + 1);
expectRoundedValue(headerBox!.y, agentPanelBox!.y + 1);
expectRoundedValue(headerBox!.width, agentPanelBox!.width - 2);
expectRoundedValue(historyBox!.x, headerBox!.x);
expectRoundedValue(historyBox!.width, headerBox!.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 expect.poll(() => getAcrylicFrame(agentPanel)).toEqual(agentPanelFrameBefore);
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);
const mobileSheetFrameBefore = await expectAcrylicOutline(mobileSheet);
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 expectUnifiedHistoryStructure(history);
await expect.poll(() => getAcrylicFrame(mobileSheet)).toEqual(mobileSheetFrameBefore);
await expect(agentPanel).toHaveCSS("box-shadow", "none");
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);
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 getAcrylicFrame(surface: Locator) {
return surface.evaluate((element) => {
const style = getComputedStyle(element);
return {
borderBottomColor: style.borderBottomColor,
borderLeftColor: style.borderLeftColor,
borderRightColor: style.borderRightColor,
borderTopColor: style.borderTopColor,
boxShadow: style.boxShadow
};
});
}
async function expectAcrylicOutline(surface: Locator) {
const frame = await getAcrylicFrame(surface);
expect(frame.borderTopColor).not.toBe("rgba(0, 0, 0, 0)");
expect(frame.borderRightColor).not.toBe("rgba(0, 0, 0, 0)");
expect(frame.borderBottomColor).not.toBe("rgba(0, 0, 0, 0)");
expect(frame.borderLeftColor).not.toBe("rgba(0, 0, 0, 0)");
expect(frame.boxShadow).toContain("0px 1px 0px 0px inset");
expect(frame.boxShadow).toContain("0px 0px 0px 1px");
return frame;
}
async function expectUnifiedHistoryStructure(history: Locator) {
const structure = await history.evaluate((element) => {
const paragraphs = Array.from(element.querySelectorAll("p"));
const heading = paragraphs.find((paragraph) => paragraph.textContent?.trim() === "历史记录");
const count = paragraphs.find((paragraph) =>
/^(?:\d+ 个会话|暂无历史会话)$/.test(paragraph.textContent?.trim() ?? "")
);
return {
countTagName: count?.tagName ?? null,
headingTagName: heading?.tagName ?? null,
siblings: Boolean(heading && count && heading.parentElement === count.parentElement)
};
});
expect(structure).toEqual({
countTagName: "P",
headingTagName: "P",
siblings: true
});
await expect(history.getByRole("button", { name: "重命名对话" }).first()).toBeVisible();
await expect(history.getByRole("button", { name: "删除对话" }).first()).toBeVisible();
}
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("0px");
expect(presentation.borderRightWidth).toBe("0px");
expect(presentation.borderTopWidth).toBe("0px");
expect(presentation.boxShadow).toContain("0px 12px 12px -12px");
}
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
};
}