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 }; }