feat: add project-aware agent workspace
This commit is contained in:
+175
-594
@@ -1,9 +1,11 @@
|
||||
import { expect, test, type Locator } from "@playwright/test";
|
||||
import { mockAgentApi } from "./support/mock-agent-api";
|
||||
import { mockWorkbenchContext } from "./support/mock-workbench-context";
|
||||
import { expectThemeColor, readColorAlpha, readThemeColor } from "./support/theme-colors";
|
||||
import { expectThemeColor } from "./support/theme-colors";
|
||||
|
||||
// Retain the historical filename; the Agent now has an independent solid theme.
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await mockWorkbenchContext(page);
|
||||
await mockAgentApi(page, {
|
||||
sessions: [
|
||||
@@ -18,325 +20,189 @@ test.beforeEach(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)));
|
||||
await Promise.all(
|
||||
element
|
||||
.getAnimations({ subtree: true })
|
||||
.filter((animation) => animation.effect?.getTiming().iterations !== Infinity)
|
||||
.map((animation) => animation.finished.catch(() => undefined))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test("desktop Agent docks beside the workspace with a solid reading surface", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
|
||||
|
||||
const topBar = page.locator("header.acrylic-navigation");
|
||||
const workspace = page.locator("#main-workspace");
|
||||
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]');
|
||||
const agentHeader = agentPanel.locator(".agent-panel-header");
|
||||
const agentConversationScroll = agentPanel.locator(".agent-conversation-scroll");
|
||||
const agentControls = agentPanel.locator(".agent-panel-band").last();
|
||||
|
||||
await expect(topBar).toBeVisible();
|
||||
await expect(workspace).toBeVisible();
|
||||
await expect(agentPanel).toBeVisible();
|
||||
await expect(agentHeader).toBeVisible();
|
||||
await expect(agentControls).toBeVisible();
|
||||
await waitForAnimations(agentPanel);
|
||||
|
||||
await expectAcrylicSurface(topBar);
|
||||
await expectThemeColor(agentPanel, "background-color", "--agent-dock-surface");
|
||||
await expect(agentPanel).toHaveCSS("backdrop-filter", "none");
|
||||
await expect(agentPanel).toHaveCSS("border-radius", "0px");
|
||||
await expect(agentPanel).toHaveCSS("border-top-width", "0px");
|
||||
await expect(agentPanel).toHaveCSS("border-bottom-width", "0px");
|
||||
await expect(agentPanel).toHaveCSS("border-left-width", "0px");
|
||||
await expect(agentPanel).toHaveCSS("border-right-width", "1px");
|
||||
await expectThemeColor(agentPanel, "border-right-color", "--surface-divider");
|
||||
await expect(agentPanel).toHaveCSS("box-shadow", "none");
|
||||
await expect(agentPanel).toHaveAttribute("data-agent-starting", "true");
|
||||
await expect(agentControls).toHaveCSS("border-top-width", "0px");
|
||||
await expect(agentControls).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
|
||||
await expectAcrylicSurface(agentPanel.locator("form.agent-panel-control"));
|
||||
const inputBody = agentPanel.locator(".agent-panel-input-body");
|
||||
const inputTools = agentPanel.locator(".agent-panel-input-tools");
|
||||
await expectThemeColor(inputBody, "background-color", "--agent-dock-reading");
|
||||
await expectThemeColor(inputBody, "border-color", "--surface-border");
|
||||
await expect(inputBody).toHaveCSS("display", "block");
|
||||
await expect(inputTools).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
|
||||
const startComposer = agentPanel.locator(".agent-panel-start-composer");
|
||||
const suggestions = agentPanel.getByRole("group", { name: "推荐问题" });
|
||||
await expect(suggestions.getByRole("button")).toHaveCount(3);
|
||||
await expect(agentPanel.getByRole("heading", { name: "我能帮你处理什么?" })).toBeVisible();
|
||||
await expect(agentPanel.locator(".agent-panel-main-content")).toBeHidden();
|
||||
|
||||
const panelBox = await agentPanel.boundingBox();
|
||||
expect(panelBox).not.toBeNull();
|
||||
expect(panelBox!.x).toBe(0);
|
||||
const topBarBox = await topBar.boundingBox();
|
||||
expect(panelBox!.y).toBe(topBarBox!.y + topBarBox!.height);
|
||||
expect(panelBox!.y + panelBox!.height).toBe(page.viewportSize()!.height);
|
||||
expect(panelBox!.width).toBe(500);
|
||||
await expect.poll(async () => (await workspace.boundingBox())?.x).toBe(500);
|
||||
const headerBox = await agentHeader.boundingBox();
|
||||
const workspaceBox = await workspace.boundingBox();
|
||||
expect(headerBox!.height).toBe(topBarBox!.height);
|
||||
expect(headerBox!.y).toBe(topBarBox!.y + topBarBox!.height);
|
||||
expect(workspaceBox!.y).toBe(topBarBox!.y);
|
||||
await expect(topBar).toHaveCSS("box-shadow", "none");
|
||||
await expectAcrylicSurface(agentHeader);
|
||||
await expect(agentHeader.locator(":scope > div").first()).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
|
||||
expect(await topBar.evaluate((element) => getComputedStyle(element).borderBottomColor)).toBe(
|
||||
await agentPanel.evaluate((element) => getComputedStyle(element).borderRightColor)
|
||||
);
|
||||
await expect(agentConversationScroll).toHaveCSS("scrollbar-gutter", "stable both-edges");
|
||||
|
||||
const beforeFocus = await getSurfacePresentation(agentPanel);
|
||||
await page.getByPlaceholder("询问供水问题,或描述需要处理的任务").focus();
|
||||
await expectThemeColor(inputBody, "border-color", "--field-focus-border");
|
||||
await expect(inputBody).toHaveCSS("box-shadow", "none");
|
||||
await expect(agentPanel.locator("form.agent-panel-control > [data-slot=input-group]")).toHaveCSS("border-width", "0px");
|
||||
await expect.poll(() => getSurfacePresentation(agentPanel)).toEqual(beforeFocus);
|
||||
await agentPanel.getByRole("button", { name: "选择对话归属" }).click();
|
||||
const projectMenu = page.getByRole("menu");
|
||||
await expect(projectMenu.getByRole("menuitem", { name: "个人会话" })).toBeVisible();
|
||||
await expect(projectMenu.getByRole("menuitem", { name: "当前项目" })).toBeVisible();
|
||||
await expect(projectMenu.getByRole("menuitem", { name: "新建项目" })).toBeVisible();
|
||||
await expect(projectMenu.getByRole("menuitem", { name: "切换项目" })).toHaveCount(0);
|
||||
await page.screenshot({ path: "test-results/agent-history-ui/project-menu.png" });
|
||||
await page.keyboard.press("Escape");
|
||||
await page.screenshot({ path: "test-results/agent-history-ui/desktop-dock.png" });
|
||||
|
||||
for (const viewport of [
|
||||
{ width: 1024, height: 768, panelWidth: 500 },
|
||||
{ width: 1920, height: 1080, panelWidth: 540 }
|
||||
]) {
|
||||
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
||||
await expect(agentPanel).toHaveCSS("width", `${viewport.panelWidth}px`);
|
||||
await expect.poll(async () => (await workspace.boundingBox())?.x).toBe(viewport.panelWidth);
|
||||
const resizedPanelBox = await agentPanel.boundingBox();
|
||||
expect(resizedPanelBox!.x).toBe(0);
|
||||
expect(resizedPanelBox!.y).toBe(topBarBox!.y + topBarBox!.height);
|
||||
expect(resizedPanelBox!.y + resizedPanelBox!.height).toBe(viewport.height);
|
||||
await page.screenshot({
|
||||
path: `test-results/agent-history-ui/desktop-dock-${viewport.width}.png`
|
||||
});
|
||||
}
|
||||
|
||||
await page.setViewportSize({ width: 1440, height: 640 });
|
||||
const handle = page.getByRole("separator", { name: "调整 Agent 面板宽度" });
|
||||
for (const width of [400, 640]) {
|
||||
const box = (await handle.boundingBox())!;
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(width, box.y + box.height / 2);
|
||||
await page.mouse.up();
|
||||
await page.mouse.move(1000, 450);
|
||||
await expect(agentPanel).toHaveCSS("width", `${width}px`);
|
||||
expect(await startComposer.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true);
|
||||
expect(await inputTools.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true);
|
||||
const textarea = agentPanel.locator("textarea");
|
||||
await textarea.fill("核对近期供水压力变化,说明监测证据和需要现场核验的事项。".repeat(20));
|
||||
const composerBox = (await startComposer.boundingBox())!;
|
||||
expect(composerBox.y).toBeGreaterThanOrEqual(topBarBox!.height * 2);
|
||||
expect(composerBox.y + composerBox.height).toBeLessThanOrEqual(640);
|
||||
await textarea.fill("");
|
||||
await agentPanel.screenshot({ path: `test-results/agent-history-ui/desktop-material-${width}.png` });
|
||||
}
|
||||
});
|
||||
|
||||
test("desktop Agent portal menus keep local material and respect reduced transparency", async ({ page }) => {
|
||||
test("Agent separates conversation navigation, context and composer from the map theme", async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
|
||||
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
|
||||
const header = panel.locator(".agent-panel-header");
|
||||
const context = panel.locator(".agent-chat-context-bar");
|
||||
const form = panel.locator("form");
|
||||
await waitForAnimations(panel);
|
||||
await expect(page.locator("header.acrylic-navigation")).toHaveCSS("backdrop-filter", /blur/);
|
||||
await expect(header).toHaveCSS("background-color", "rgb(255, 255, 255)");
|
||||
await expect(header).toHaveCSS("backdrop-filter", "none");
|
||||
await expect(header.getByLabel("Agent 状态")).toHaveCSS("width", "64px");
|
||||
await expect(header.getByRole("group", { name: "对话操作" }).getByRole("button")).toHaveCount(3);
|
||||
await expect(panel.getByRole("heading", { name: "有什么可以帮你?" })).toBeVisible();
|
||||
await expect(context).toHaveCount(0);
|
||||
await expect(context.getByRole("button")).toHaveCount(0);
|
||||
await expect(form).toHaveCount(1);
|
||||
await expect(form).toHaveCSS("background-color", "rgb(255, 255, 255)");
|
||||
const panelBox = (await panel.boundingBox())!;
|
||||
const headerBox = (await header.boundingBox())!;
|
||||
const divider = panel.locator("[data-agent-header-divider]");
|
||||
const contextBox = (await divider.boundingBox())!;
|
||||
expect(panelBox.x).toBe(0);
|
||||
expect(contextBox.y).toBeGreaterThanOrEqual(headerBox.y + headerBox.height);
|
||||
await form.locator("textarea").focus();
|
||||
await expectThemeColor(form, "border-color", "--action-blue");
|
||||
await header.getByRole("button", { name: "选择主题" }).click();
|
||||
await expect(page.getByRole("menuitem", { name: "新建主题", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("menuitem", { name: "新建项目", exact: true })).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
await panel.screenshot({ path: "test-results/agent-history-ui/agent-new-start.png" });
|
||||
const handle = page.getByRole("separator", { name: "调整 Agent 面板宽度" });
|
||||
for (const [key, width] of [["Home", 400], ["End", 640]] as const) {
|
||||
await handle.focus();
|
||||
await page.keyboard.press(key);
|
||||
await expect.poll(async () => Math.round((await panel.boundingBox())!.width)).toBe(width);
|
||||
const bounds = (await header.boundingBox())!;
|
||||
const contextBounds = (await divider.boundingBox())!;
|
||||
const actions = (await header.getByRole("group", { name: "对话操作" }).boundingBox())!;
|
||||
const identity = (await header.getByText("供水助手", { exact: true }).boundingBox())!;
|
||||
expect(actions.x).toBeGreaterThan(identity.x + identity.width);
|
||||
expect(actions.x + actions.width).toBeLessThanOrEqual(bounds.x + bounds.width - 12);
|
||||
await page.screenshot({
|
||||
path: `/tmp/nexttjwater-orb-64/header-${width}.png`,
|
||||
clip: { ...bounds, height: contextBounds.y + contextBounds.height - bounds.y }
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("Agent portal menus use solid surfaces and return focus with reduced transparency", async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
|
||||
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
await cdp.send("Emulation.setEmulatedMedia", {
|
||||
features: [{ name: "prefers-reduced-transparency", value: "reduce" }]
|
||||
});
|
||||
for (const name of ["选择 Agent 模型", "权限批准模式"]) {
|
||||
const trigger = panel.getByRole("button", { name });
|
||||
await trigger.click();
|
||||
const menu = page.getByRole("menu");
|
||||
await expect(menu).toBeVisible();
|
||||
await waitForAnimations(menu);
|
||||
await expect(menu).toHaveAttribute("data-agent-presentation", "desktop-dock");
|
||||
await expect(menu).toHaveCSS("background-color", "rgb(255, 255, 255)");
|
||||
await expect(menu).toHaveCSS("backdrop-filter", "none");
|
||||
expect(await menu.evaluate((element) => element.closest("aside"))).toBeNull();
|
||||
await expectThemeColor(menu, "background-color", "--agent-dock-overlay-surface");
|
||||
await expectAcrylicSurface(menu);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(trigger).toBeFocused();
|
||||
}
|
||||
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
await cdp.send("Emulation.setEmulatedMedia", {
|
||||
features: [{ name: "prefers-reduced-transparency", value: "reduce" }]
|
||||
});
|
||||
expect(await page.evaluate(() => matchMedia("(prefers-reduced-transparency: reduce)").matches)).toBe(true);
|
||||
await panel.getByRole("button", { name: "选择 Agent 模型" }).click();
|
||||
const menu = page.getByRole("menu");
|
||||
await expectThemeColor(menu, "background-color", "--palette-paper");
|
||||
await expect(menu).toHaveCSS("backdrop-filter", "none");
|
||||
await page.keyboard.press("Escape");
|
||||
await panel.getByRole("button", { name: "打开 Agent 历史记录" }).click();
|
||||
const history = panel.getByRole("region", { name: "Agent 历史记录" });
|
||||
await expectThemeColor(panel.locator(".agent-panel-header"), "background-color", "--palette-paper");
|
||||
await expect(panel.locator(".agent-panel-header")).toHaveCSS("backdrop-filter", "none");
|
||||
await expect(history).toHaveCSS("backdrop-filter", "none");
|
||||
});
|
||||
|
||||
test("desktop Agent history remains usable inside the docked navigation pane", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
|
||||
|
||||
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]');
|
||||
const historyButton = agentPanel.getByRole("button", {
|
||||
name: "打开 Agent 历史记录"
|
||||
});
|
||||
|
||||
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 expectUnifiedHistoryStructure(history);
|
||||
await expectThemeColor(agentPanel.locator(".agent-panel-header"), "background-color", "--agent-dock-overlay-surface");
|
||||
await expectAcrylicSurface(agentPanel.locator(".agent-panel-header"));
|
||||
await expect(history).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
|
||||
await expect(history).toHaveCSS("backdrop-filter", "none");
|
||||
|
||||
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 expectThemeColor(renameInput, "border-color", "--action-blue-hover");
|
||||
await expect(editingItem).toHaveCSS("border-color", "rgba(0, 0, 0, 0)");
|
||||
await page.screenshot({ path: "test-results/agent-history-ui/desktop-rename.png" });
|
||||
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");
|
||||
});
|
||||
|
||||
test("desktop Agent press feedback keeps pointer clicks and keyboard activation stable", async ({ page }) => {
|
||||
test("Agent history keeps rename, cancel and focus restoration usable", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
|
||||
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
|
||||
await waitForAnimations(panel);
|
||||
const trigger = panel.getByRole("button", { name: "打开 Agent 历史记录" });
|
||||
const history = panel.getByRole("region", { name: "Agent 历史记录" });
|
||||
const before = await trigger.boundingBox();
|
||||
await page.mouse.move(before!.x + before!.width / 2, before!.y + before!.height / 2);
|
||||
await page.mouse.down();
|
||||
const pressed = await trigger.boundingBox();
|
||||
expect(pressed!.y).toBe(before!.y + 1);
|
||||
expect(pressed!.width).toBe(before!.width);
|
||||
await page.mouse.up();
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true");
|
||||
await trigger.click();
|
||||
const history = page.getByRole("region", { name: "Agent 历史记录" });
|
||||
await expect(history).toBeVisible();
|
||||
await waitForAnimations(panel);
|
||||
|
||||
await page.keyboard.press("Tab");
|
||||
await trigger.focus();
|
||||
expect(await trigger.evaluate((element) => element.matches(":focus-visible"))).toBe(true);
|
||||
const focused = await trigger.boundingBox();
|
||||
await page.keyboard.down("Space");
|
||||
expect(await trigger.boundingBox()).toEqual(focused);
|
||||
await page.keyboard.up("Space");
|
||||
await history.getByRole("button", { name: /会话操作/ }).first().click();
|
||||
await page.getByRole("menuitem", { name: "重命名对话", exact: true }).click();
|
||||
await expect(history.getByRole("textbox")).toBeFocused();
|
||||
await history.getByRole("textbox").fill("新的供水核验主题");
|
||||
await history.getByRole("button", { name: "取消重命名" }).click();
|
||||
await expect(history.getByText("城南供水压力复核", { exact: true })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(history).toBeHidden();
|
||||
await expect(trigger).toBeFocused();
|
||||
});
|
||||
|
||||
test("mobile Agent mirrors the desktop floating history surface", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
test("Agent navigation responds to both pointer and keyboard activation", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
|
||||
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
|
||||
const trigger = panel.getByRole("button", { name: "打开 Agent 历史记录" });
|
||||
await trigger.click();
|
||||
await expect(panel.getByRole("button", { name: "打开 Agent 历史记录", includeHidden: true })).toHaveAttribute("aria-expanded", "true");
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(trigger).toBeFocused();
|
||||
await page.keyboard.press("Space");
|
||||
await expect(page.getByRole("dialog", { name: "历史对话" }).getByRole("searchbox")).toBeFocused();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(trigger).toBeFocused();
|
||||
});
|
||||
|
||||
test("mobile Agent opens full height with an unobstructed message region and layered Escape", async ({
|
||||
page
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 320, 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 sheet = page.getByRole("region", { name: "Agent 工作台抽屉" });
|
||||
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
|
||||
await expect(sheet).toHaveAttribute("data-snap", "full");
|
||||
await expect(panel).toBeVisible();
|
||||
const header = panel.locator(".agent-panel-header");
|
||||
await expect(header.getByLabel("Agent 状态")).toHaveCSS("width", "64px");
|
||||
const controls = await panel
|
||||
.locator(".agent-panel-header > div")
|
||||
.first()
|
||||
.getByRole("button")
|
||||
.evaluateAll((elements) =>
|
||||
elements.map((element) => {
|
||||
const { left, right } = element.getBoundingClientRect();
|
||||
return { left, right };
|
||||
})
|
||||
);
|
||||
for (let index = 0; index < controls.length; index += 1) {
|
||||
expect(controls[index].left).toBeGreaterThanOrEqual(0);
|
||||
expect(controls[index].right).toBeLessThanOrEqual(320);
|
||||
if (index > 0) expect(controls[index].left).toBeGreaterThanOrEqual(controls[index - 1].right);
|
||||
}
|
||||
const headerBounds = (await header.boundingBox())!;
|
||||
const contextBounds = (await panel.locator("[data-agent-header-divider]").boundingBox())!;
|
||||
await page.screenshot({
|
||||
path: "/tmp/nexttjwater-orb-64/header-320.png",
|
||||
clip: { x: 0, y: headerBounds.y, width: 320, height: contextBounds.y + contextBounds.height - headerBounds.y }
|
||||
});
|
||||
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.screenshot({ path: "test-results/agent-history-ui/mobile-history.png" });
|
||||
|
||||
const model = panel.getByRole("button", { name: "选择 Agent 模型" });
|
||||
await model.click();
|
||||
await expect(page.getByRole("menu")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(history).toBeHidden();
|
||||
await expect(page.getByRole("menu")).toBeHidden();
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(model).toBeFocused();
|
||||
const historyButton = panel.getByRole("button", { name: "打开 Agent 历史记录" });
|
||||
await historyButton.click();
|
||||
await expect(page.getByRole("region", { name: "Agent 历史记录" })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("region", { name: "Agent 历史记录" })).toBeHidden();
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(historyButton).toBeFocused();
|
||||
});
|
||||
|
||||
test("docked Agent respects forced color mode", async ({ page }) => {
|
||||
test("Agent keeps system borders and visible controls in forced color mode", async ({ page }) => {
|
||||
await page.emulateMedia({ forcedColors: "active" });
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
|
||||
|
||||
const navigation = page.getByRole("complementary", { name: "Agent 命令面板" });
|
||||
const workspace = page.locator("#main-workspace");
|
||||
await expect(navigation).toBeVisible();
|
||||
await expect(navigation).toHaveCSS("backdrop-filter", "none");
|
||||
await expect(navigation).toHaveCSS("box-shadow", "none");
|
||||
await expect(navigation).toHaveCSS("border-right-width", "1px");
|
||||
await expect(navigation).toHaveCSS("border-right-color", "rgb(0, 0, 0)");
|
||||
await expect(workspace).toBeVisible();
|
||||
await expect(page.getByRole("toolbar", { name: "工作区窗口任务栏" })).toHaveCount(0);
|
||||
await page.screenshot({ path: "test-results/agent-history-ui/forced-colors.png" });
|
||||
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
|
||||
await expect(panel).toHaveCSS("backdrop-filter", "none");
|
||||
await expect(panel).toHaveCSS("border-right-width", "1px");
|
||||
await expect(panel).toHaveCSS("border-right-color", "rgb(0, 0, 0)");
|
||||
await expect(panel.getByRole("button", { name: "发送 Agent 指令" })).toBeVisible();
|
||||
await panel.getByRole("button", { name: "打开 Agent 历史记录" }).click();
|
||||
const history = page.getByRole("dialog", { name: "历史对话", exact: true });
|
||||
await expect(history).toHaveCSS("background-color", "rgb(255, 255, 255)");
|
||||
await expect(history).toHaveCSS("border-top-color", "rgb(0, 0, 0)");
|
||||
});
|
||||
|
||||
test("long history titles keep rename and explicit delete usable across viewport sizes", async ({
|
||||
@@ -349,25 +215,25 @@ test("long history titles keep rename and explicit delete usable across viewport
|
||||
let title = originalTitle;
|
||||
let deleted = false;
|
||||
const writes: Array<{ method: string; path: string; body: unknown }> = [];
|
||||
await page.route(/\/api\/v1\/agent\/chat\/sessions(?:\?.*)?$/, (route) =>
|
||||
await page.route(/\/api\/v1\/sessions(?:\?.*)?$/, (route) =>
|
||||
route.request().method() === "OPTIONS"
|
||||
? route.fallback()
|
||||
: route.fulfill({
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
json: {
|
||||
sessions: deleted
|
||||
? []
|
||||
: [
|
||||
{
|
||||
session_id: sessionId,
|
||||
title,
|
||||
created_at: 100,
|
||||
updated_at: 200,
|
||||
run_status: "completed"
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
json: {
|
||||
sessions: deleted
|
||||
? []
|
||||
: [
|
||||
{
|
||||
session_id: sessionId,
|
||||
title,
|
||||
created_at: 100,
|
||||
updated_at: 200,
|
||||
run_status: "completed"
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
);
|
||||
await page.route("**/api/v1/session/**", async (route) => {
|
||||
const request = route.request();
|
||||
@@ -441,14 +307,15 @@ test("long history titles keep rename and explicit delete usable across viewport
|
||||
await expect(approvalButton).toBeFocused();
|
||||
const historyButton = panel.getByRole("button", { name: "打开 Agent 历史记录" });
|
||||
await historyButton.click();
|
||||
const history = panel.getByRole("region", { name: "Agent 历史记录" });
|
||||
const history = page.getByRole("region", { name: "Agent 历史记录" });
|
||||
await expect(history.locator(".agent-history-session-trigger")).toContainText(originalTitle);
|
||||
await history.getByRole("button", { name: "重命名对话", exact: true }).click();
|
||||
await history.getByRole("button", { name: /会话操作/ }).click();
|
||||
await page.getByRole("menuitem", { name: "重命名对话", exact: true }).click();
|
||||
const input = history.getByRole("textbox");
|
||||
await expect(input).toBeFocused();
|
||||
await expectThemeColor(input, "border-color", "--action-blue-hover");
|
||||
await expectThemeColor(input, "border-color", "--field-focus-border");
|
||||
await input.fill(`${originalTitle}(已复核)`);
|
||||
await expect(history.getByRole("button", { name: "保存对话主题" })).toBeVisible();
|
||||
await expect(history.getByRole("button", { name: "保存对话标题" })).toBeVisible();
|
||||
await waitForAnimations(history);
|
||||
const box = await history.boundingBox();
|
||||
expect(box!.x).toBeGreaterThanOrEqual(0);
|
||||
@@ -457,7 +324,7 @@ test("long history titles keep rename and explicit delete usable across viewport
|
||||
true
|
||||
);
|
||||
const inputBox = await input.boundingBox();
|
||||
const saveBox = await history.getByRole("button", { name: "保存对话主题" }).boundingBox();
|
||||
const saveBox = await history.getByRole("button", { name: "保存对话标题" }).boundingBox();
|
||||
expect(inputBox!.width).toBeGreaterThan(40);
|
||||
expect(inputBox!.x + inputBox!.width).toBeLessThanOrEqual(saveBox!.x);
|
||||
await page.screenshot({ path: `test-results/agent-history-ui/${width}-long-rename.png` });
|
||||
@@ -465,9 +332,10 @@ test("long history titles keep rename and explicit delete usable across viewport
|
||||
await expect(history.locator(".agent-history-session-trigger")).toContainText(originalTitle);
|
||||
expect(writes).toEqual([]);
|
||||
|
||||
await history.getByRole("button", { name: "重命名对话", exact: true }).click();
|
||||
await history.getByRole("button", { name: /会话操作/ }).click();
|
||||
await page.getByRole("menuitem", { name: "重命名对话", exact: true }).click();
|
||||
await input.fill("城南压力复核(已复核)");
|
||||
await history.getByRole("button", { name: "保存对话主题" }).click();
|
||||
await history.getByRole("button", { name: "保存对话标题" }).click();
|
||||
await expect(history.locator(".agent-history-session-trigger")).toContainText(
|
||||
"城南压力复核(已复核)"
|
||||
);
|
||||
@@ -478,12 +346,14 @@ test("long history titles keep rename and explicit delete usable across viewport
|
||||
body: { title: "城南压力复核(已复核)", is_title_manually_edited: true }
|
||||
}
|
||||
]);
|
||||
await history.getByRole("button", { name: "删除对话", exact: true }).click();
|
||||
await history.getByRole("button", { name: /会话操作/ }).click();
|
||||
await page.getByRole("menuitem", { name: "删除对话", exact: true }).click();
|
||||
await expect(history.getByText("删除后无法从历史记录中恢复")).toBeVisible();
|
||||
await expect(history.getByRole("button", { name: "确认", exact: true })).toBeVisible();
|
||||
await history.getByRole("button", { name: "取消删除" }).click();
|
||||
expect(writes).toHaveLength(1);
|
||||
await history.getByRole("button", { name: "删除对话", exact: true }).click();
|
||||
await history.getByRole("button", { name: /会话操作/ }).click();
|
||||
await page.getByRole("menuitem", { name: "删除对话", exact: true }).click();
|
||||
await history.getByRole("button", { name: "确认", exact: true }).click();
|
||||
await expect(history.getByText("暂无历史记录", { exact: true })).toBeVisible();
|
||||
expect(writes).toHaveLength(2);
|
||||
@@ -497,292 +367,3 @@ test("long history titles keep rename and explicit delete usable across viewport
|
||||
await expect(historyButton).toBeFocused();
|
||||
}
|
||||
});
|
||||
|
||||
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
|
||||
};
|
||||
}),
|
||||
readThemeColor(agentSurface, "--acrylic-outline")
|
||||
]);
|
||||
|
||||
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) {
|
||||
return readColorAlpha(color);
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { parseAnswerUiBlock } from "../../src/features/agent/answer-ui/contract";
|
||||
import { mockWorkbenchContext } from "./support/mock-workbench-context";
|
||||
import { TEST_PROJECT_ID } from "./support/mock-scada-api";
|
||||
|
||||
// Explicit opt-in: two real conversation turns against a local dev Agent; ordinary learning checks may also run.
|
||||
const realAgentUrl = process.env.TJWATER_ANSWER_UI_REAL_AGENT_URL;
|
||||
@@ -16,6 +17,7 @@ test("real model publishes local components, restores them and receives form inp
|
||||
const target = new URL(realAgentUrl!);
|
||||
expect(["127.0.0.1", "localhost"]).toContain(target.hostname);
|
||||
const base = `${target.origin}/api/v1`;
|
||||
const headers = { "X-Project-Id": TEST_PROJECT_ID };
|
||||
const health = await request.get(`${target.origin}/health`);
|
||||
expect(health.ok()).toBe(true);
|
||||
expect(await health.json()).toMatchObject({ ready: true, warmed_up: true });
|
||||
@@ -33,12 +35,12 @@ test("real model publishes local components, restores them and receives form inp
|
||||
})};`
|
||||
})
|
||||
);
|
||||
const created = await request.post(`${base}/session`, { data: {} });
|
||||
const created = await request.post(`${base}/session`, { headers, data: {} });
|
||||
expect(created.ok()).toBe(true);
|
||||
const id = (await created.json()).session_id as string;
|
||||
const title = `本地交互回答验收 ${Date.now()}`;
|
||||
const renamed = await request.patch(`${base}/session/${encodeURIComponent(id)}/title`, {
|
||||
data: { title }
|
||||
headers, data: { title }
|
||||
});
|
||||
expect(renamed.ok()).toBe(true);
|
||||
page.on("pageerror", (error) => console.error("Real answer browser error:", error.message));
|
||||
@@ -47,11 +49,9 @@ test("real model publishes local components, restores them and receives form inp
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
|
||||
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
|
||||
await panel.getByRole("button", { name: "选择对话归属" }).click();
|
||||
await page.getByRole("menuitem", { name: "个人会话", exact: true }).click();
|
||||
await expect(panel.getByRole("button", { name: "选择 Agent 模型" })).toBeEnabled();
|
||||
await panel.getByRole("button", { name: "打开 Agent 历史记录" }).click();
|
||||
await panel
|
||||
await page.getByRole("dialog", { name: "历史对话", exact: true })
|
||||
.getByRole("button", { name: new RegExp(title) })
|
||||
.first()
|
||||
.click();
|
||||
@@ -76,11 +76,11 @@ test("real model publishes local components, restores them and receives form inp
|
||||
await expect(panel.getByRole("button", { name: "发送 Agent 指令" })).toBeVisible({
|
||||
timeout: 180_000
|
||||
});
|
||||
await panel.getByRole("button", { name: "数据", exact: true }).first().click();
|
||||
await panel.getByRole("tab", { name: "数据", exact: true }).first().click();
|
||||
await expect(panel.getByRole("table").first()).toContainText("0.18");
|
||||
await expect(panel.getByRole("table").first()).toContainText("10:00");
|
||||
await expect(panel.getByLabel("现场位置(必填)")).toHaveValue("");
|
||||
const storedResponse = await request.get(`${base}/session/${encodeURIComponent(id)}`);
|
||||
const storedResponse = await request.get(`${base}/session/${encodeURIComponent(id)}`, { headers });
|
||||
expect(storedResponse.ok()).toBe(true);
|
||||
const stored = await storedResponse.json();
|
||||
expect(stored.is_streaming).toBe(false);
|
||||
@@ -122,7 +122,7 @@ test("real model publishes local components, restores them and receives form inp
|
||||
});
|
||||
await panel.screenshot({ path: "test-results/answer-ui-real-model.png" });
|
||||
} finally {
|
||||
const deleted = await request.delete(`${base}/session/${encodeURIComponent(id)}`);
|
||||
const deleted = await request.delete(`${base}/session/${encodeURIComponent(id)}`, { headers });
|
||||
expect(deleted.status()).toBe(204);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,88 +3,18 @@ import { expect, test } from "@playwright/test";
|
||||
import { mockAgentApi } from "./support/mock-agent-api";
|
||||
import { mockWorkbenchContext } from "./support/mock-workbench-context";
|
||||
|
||||
const block = {
|
||||
version: "answer-ui@1",
|
||||
block_id: "pressure",
|
||||
fallback_text: "演练压力读数,仍待现场核验。",
|
||||
spec: {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: {
|
||||
type: "Stack",
|
||||
props: { columns: "one", gap: "normal" },
|
||||
children: ["metrics", "chart", "evidence", "location", "form"]
|
||||
},
|
||||
metrics: {
|
||||
type: "Stack",
|
||||
props: { columns: "two", gap: "compact" },
|
||||
children: ["pressure", "flow"]
|
||||
},
|
||||
pressure: {
|
||||
type: "Metric",
|
||||
props: { label: "压力", value: "0.30", unit: "MPa", source: "演练数据" },
|
||||
children: []
|
||||
},
|
||||
flow: {
|
||||
type: "Metric",
|
||||
props: { label: "流量", value: "18", unit: "m³/h", source: "演练数据" },
|
||||
children: []
|
||||
},
|
||||
chart: {
|
||||
type: "Chart",
|
||||
props: {
|
||||
title: "演练监测趋势",
|
||||
chart_type: "line",
|
||||
x_data: ["08:00", "09:00"],
|
||||
series: [{ name: "压力(MPa)", data: [0.3, 0.28] }],
|
||||
source: "浏览器验收演练数据"
|
||||
},
|
||||
children: []
|
||||
},
|
||||
evidence: {
|
||||
type: "Evidence",
|
||||
props: {
|
||||
title: "查看演练证据",
|
||||
summary: "仅用于组件验收,不代表已核验现场事实。",
|
||||
source: "浏览器演练",
|
||||
evidence_id: "DEMO-001",
|
||||
observed_at: null,
|
||||
kind: "demo"
|
||||
},
|
||||
children: []
|
||||
},
|
||||
location: {
|
||||
type: "MapLocation",
|
||||
props: {
|
||||
label: "定位演练监测点",
|
||||
layer: "scada",
|
||||
feature_ids: ["DEMO-P001"],
|
||||
source: "演练监测点"
|
||||
},
|
||||
children: []
|
||||
},
|
||||
form: {
|
||||
type: "FollowUpForm",
|
||||
props: {
|
||||
title: "补充核验资料",
|
||||
description: "填写后由你主动发送给 Agent。",
|
||||
fields: [{ name: "location", label: "现场位置", kind: "text", required: true }]
|
||||
},
|
||||
children: []
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
import { answerUiDemoBlock as block } from "./support/answer-ui-demo";
|
||||
import { captureReadingGeometry } from "./support/reading-geometry";
|
||||
|
||||
for (const width of [1440, 375, 320]) {
|
||||
test(`local rich answers stream, restore and submit at ${width}px`, async ({ page }) => {
|
||||
test(`local rich answers stream, restore and submit at ${width}px`, async ({ page, context }, testInfo) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await mockWorkbenchContext(page);
|
||||
await mockWorkbenchContext(page, undefined, undefined, {}, [], { runtimeMode: "backend" });
|
||||
await mockAgentApi(page, {
|
||||
sessions: [
|
||||
{
|
||||
session_id: "browser-session-1",
|
||||
session_id: "browser-history-1",
|
||||
title: "演练结构化回答",
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now()
|
||||
@@ -100,12 +30,12 @@ for (const width of [1440, 375, 320]) {
|
||||
data: { content: "仍需用户补充现场核验资料。", ui_block_ids: ["pressure"] }
|
||||
}
|
||||
];
|
||||
await page.route("**/api/v1/session/browser-session-1", async (route) => {
|
||||
await page.route("**/api/v1/session/browser-history-1?**", async (route) => {
|
||||
if (route.request().method() !== "GET") return route.fallback();
|
||||
await route.fulfill({
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
json: {
|
||||
session_id: "browser-session-1",
|
||||
session_id: "browser-history-1",
|
||||
title: "演练结构化回答",
|
||||
purpose: "ordinary_chat",
|
||||
status: "active",
|
||||
@@ -191,17 +121,43 @@ for (const width of [1440, 375, 320]) {
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(panelWidth, box.y + box.height / 2);
|
||||
await page.mouse.up();
|
||||
await expect(panel).toHaveCSS("width", `${panelWidth}px`);
|
||||
await expect.poll(async () => Math.round((await panel.boundingBox())!.width)).toBe(panelWidth);
|
||||
const columns = await ui
|
||||
.locator(".answer-ui-stack-comparison")
|
||||
.evaluate((element) => getComputedStyle(element).gridTemplateColumns.split(" ").length);
|
||||
expect(columns).toBe(panelWidth === 640 ? 2 : 1);
|
||||
expect(columns).toBe(2);
|
||||
}
|
||||
}
|
||||
await expect(ui.getByText("0.30", { exact: false }).first()).toBeVisible();
|
||||
await ui.getByRole("button", { name: "数据", exact: true }).click();
|
||||
await expect(panel.getByText("交互回答", { exact: true })).toBeVisible();
|
||||
await panel.locator(".agent-conversation-scroll").evaluate((element) => { element.scrollTop = 0; });
|
||||
await expect(panel.getByText("查看演练监测资料", { exact: true })).toBeVisible();
|
||||
await panel.screenshot({ path: `test-results/agent-chat-redesign-${width}.png` });
|
||||
const lineTab = ui.getByRole("tab", { name: "折线", exact: true });
|
||||
await lineTab.focus();
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await expect(ui.getByRole("tab", { name: "柱状", exact: true })).toBeFocused();
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await expect(ui.getByRole("tab", { name: "数据", exact: true })).toHaveAttribute("aria-selected", "true");
|
||||
await expect(ui.getByRole("table")).toContainText("0.28");
|
||||
const evidence = ui.locator("summary", { hasText: "查看演练证据" });
|
||||
await ui.getByRole("button", { name: "参考压力(MPa)", exact: true }).click();
|
||||
await expect(ui.getByRole("columnheader", { name: /参考压力/ })).toHaveCount(0);
|
||||
await ui.getByRole("button", { name: "参考压力(MPa)", exact: true }).click();
|
||||
await expect(ui.getByRole("columnheader", { name: /参考压力/ })).toBeVisible();
|
||||
await expect(ui.getByRole("combobox", { name: "区间起点" })).toBeHidden();
|
||||
await ui.getByRole("button", { name: "筛选数据范围" }).click();
|
||||
await ui.getByRole("combobox", { name: "区间起点" }).click();
|
||||
await page.getByRole("option", { name: "09:00", exact: true }).click();
|
||||
await expect(ui.getByRole("table")).not.toContainText("08:00");
|
||||
await ui.getByRole("button", { name: "筛选数据范围" }).click();
|
||||
await expect(ui.getByText("09:00 至 09:00", { exact: true })).toBeVisible();
|
||||
await expect(ui.getByRole("table")).not.toContainText("08:00");
|
||||
await ui.getByRole("button", { name: "筛选数据范围" }).click();
|
||||
await ui.getByRole("button", { name: "重置范围" }).click();
|
||||
await expect(ui.getByRole("table")).toContainText("08:00");
|
||||
await ui.getByRole("button", { name: "筛选数据范围" }).click();
|
||||
expect(prompts).toHaveLength(1);
|
||||
const evidence = ui.getByRole("button", { name: /查看演练证据/ });
|
||||
await evidence.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(ui.getByText("仅用于组件验收,不代表已核验现场事实。")).toBeVisible();
|
||||
@@ -214,9 +170,51 @@ for (const width of [1440, 375, 320]) {
|
||||
expect(bounds).toBe(true);
|
||||
await ui.getByRole("table").scrollIntoViewIfNeeded();
|
||||
await panel.screenshot({ path: `test-results/answer-ui-${width}.png` });
|
||||
// Restore from canonical persisted parts through the actual history selector.
|
||||
const copy = panel.getByRole("button", { name: "复制回复", exact: true }).first();
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await copy.click();
|
||||
await expect(panel.getByRole("button", { name: "回复已复制", exact: true }).first()).toBeVisible();
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toContain("仍需用户补充现场核验资料。");
|
||||
// The same editor and data selection move into the workspace and back.
|
||||
await ui.getByLabel("现场位置(必填)").fill("尚未提交的现场位置");
|
||||
await ui.getByRole("tab", { name: "数据", exact: true }).click();
|
||||
await ui.getByRole("textbox", { name: "搜索演练监测趋势" }).fill("09:00");
|
||||
await ui.getByRole("checkbox", { name: "选择第 2 行" }).check();
|
||||
await panel.getByRole("button", { name: "展开查看", exact: true }).click();
|
||||
const workspace = page.getByRole("region", { name: "展开报告工作区", exact: true });
|
||||
await expect(workspace).toBeVisible();
|
||||
await expect(page.getByLabel("现场位置(必填)")).toHaveCount(1);
|
||||
await expect(workspace.getByLabel("现场位置(必填)")).toHaveValue("尚未提交的现场位置");
|
||||
await expect(workspace.getByRole("textbox", { name: "搜索演练监测趋势" })).toHaveValue("09:00");
|
||||
await expect(workspace.getByRole("checkbox", { name: "选择第 2 行" })).toBeChecked();
|
||||
await workspace.getByRole("tab", { name: "折线", exact: true }).click();
|
||||
for (const close of await page.getByRole("button", { name: "关闭通知", exact: true }).all()) await close.click();
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
const geometry = await captureReadingGeometry(workspace, testInfo);
|
||||
expect(geometry.metricsHeight).toBeLessThan(145);
|
||||
expect(geometry.chartOffset).toBeLessThan(430);
|
||||
expect(geometry.horizontalOverflow).toBe(false);
|
||||
await page.screenshot({ path: `test-results/answer-workspace-${width}.png` });
|
||||
await workspace.getByRole("tab", { name: "数据", exact: true }).click();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(workspace).toHaveCount(0);
|
||||
await expect(ui.getByLabel("现场位置(必填)")).toHaveValue("尚未提交的现场位置");
|
||||
await expect(panel.getByRole("button", { name: "展开查看", exact: true })).toBeFocused();
|
||||
await panel.getByRole("button", { name: "展开查看", exact: true }).click();
|
||||
await workspace.locator("[data-interactive-table]").getByRole("button", { name: "针对这部分提问" }).click();
|
||||
await expect(workspace).toHaveCount(0);
|
||||
await panel.getByText("查看引用内容", { exact: true }).click();
|
||||
await expect(panel.getByText("引用内容块:pressure", { exact: false })).toBeVisible();
|
||||
expect(prompts).toHaveLength(1);
|
||||
await panel.getByRole("button", { name: "移除内容引用" }).click();
|
||||
// Leave the current editor before restoring canonical persisted history.
|
||||
await panel.getByRole("button", { name: "新建 Agent 对话" }).click();
|
||||
await expect(ui).toHaveCount(0);
|
||||
await panel.getByRole("button", { name: /历史/ }).click();
|
||||
await page.getByRole("button", { name: /演练结构化回答/ }).click();
|
||||
await page
|
||||
.locator("button.agent-history-session-trigger")
|
||||
.filter({ hasText: "演练结构化回答" })
|
||||
.click();
|
||||
await expect(ui).toHaveCount(1);
|
||||
await expect(ui.getByLabel("现场位置(必填)")).toHaveValue("");
|
||||
expect(prompts).toHaveLength(1);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { mockAgentApi } from "./support/mock-agent-api";
|
||||
import { mockWorkbenchContext } from "./support/mock-workbench-context";
|
||||
import { responsesFixture } from "./support/responses-fixture";
|
||||
|
||||
test("Agent export links download through the scoped client and preserve the conversation", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await mockWorkbenchContext(page, undefined, undefined, {}, [], { runtimeMode: "backend" });
|
||||
await mockAgentApi(page);
|
||||
const filename = "mcp-11111111-1111-1111-1111-111111111111.zip";
|
||||
const path = `/api/v1/session/browser-session-1/files/${filename}`;
|
||||
await page.route("**/api/v1/responses", (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback();
|
||||
return route.fulfill({ headers: { "Access-Control-Allow-Origin": "*", "Content-Type": "text/event-stream" }, body: responsesFixture([
|
||||
{ type: "start", messageId: "export-answer" }, { type: "text-start", id: "export-text" },
|
||||
{ type: "text-delta", id: "export-text", delta: `[下载模型文件](${path}) [外部参考](https://example.com)` },
|
||||
{ type: "text-end", id: "export-text" }, { type: "finish", finishReason: "stop" },
|
||||
]) });
|
||||
});
|
||||
let projectHeader: string | undefined;
|
||||
await page.route(`**${path}`, (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback();
|
||||
projectHeader = route.request().headers()["x-project-id"];
|
||||
return route.fulfill({ headers: { "Access-Control-Allow-Origin": "*", "Content-Type": "application/zip" }, body: "ZIP fixture" });
|
||||
});
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
|
||||
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
|
||||
await panel.locator("textarea").fill("导出当前模型");
|
||||
await panel.getByRole("button", { name: "发送 Agent 指令" }).click();
|
||||
const link = panel.getByRole("link", { name: "下载模型文件" });
|
||||
await expect(link).toBeVisible();
|
||||
await expect(panel.getByRole("button", { name: "外部参考" })).toBeVisible();
|
||||
const pageUrl = page.url();
|
||||
const downloading = page.waitForEvent("download");
|
||||
await link.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
const file = await downloading;
|
||||
expect(file.suggestedFilename()).toBe(filename);
|
||||
expect(projectHeader).toBeTruthy();
|
||||
expect(page.url()).toBe(pageUrl);
|
||||
await expect(panel.locator("textarea")).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { mockAgentApi } from "./support/mock-agent-api";
|
||||
import { mockWorkbenchContext } from "./support/mock-workbench-context";
|
||||
import { TEST_PROJECT_ID } from "./support/mock-scada-api";
|
||||
import { readColorAlpha } from "./support/theme-colors";
|
||||
|
||||
for (const width of [320, 375, 400, 640]) {
|
||||
test(`conversation title and topic stay distinct at ${width}px`, async ({ page }, testInfo) => {
|
||||
const mobile = width < 400;
|
||||
await page.setViewportSize({ width: mobile ? width : 1440, height: 900 });
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await mockWorkbenchContext(page);
|
||||
const title = "安徽省兴安农牧科技发展有限公司,附近选取不同管道核对供水压力、流量与阀门状态";
|
||||
const topicName = "兴安农牧供水异常事件与管道压力问题持续跟踪复核";
|
||||
const session = { id: "long-context", session_id: "long-context", title, topic_id: "long-topic", updated_at: Date.now(), created_at: Date.now() };
|
||||
await mockAgentApi(page, { sessions: [session] });
|
||||
const projectHeaders: string[] = [];
|
||||
await page.route("**/api/v1/topics", async (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback();
|
||||
projectHeaders.push((await route.request().allHeaders())["x-project-id"]);
|
||||
return route.fulfill({ headers: { "Access-Control-Allow-Origin": "*" }, json: { topics: [
|
||||
{ id: "long-topic", name: topicName, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }
|
||||
] } });
|
||||
});
|
||||
await page.route("**/api/v1/session/long-context?**", (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback();
|
||||
return route.fulfill({ headers: { "Access-Control-Allow-Origin": "*" }, json: {
|
||||
...session, purpose: "ordinary_chat", messages: [], is_streaming: false
|
||||
} });
|
||||
});
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: mobile ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
|
||||
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
|
||||
if (!mobile) {
|
||||
await page.getByRole("separator", { name: "调整 Agent 面板宽度" }).focus();
|
||||
await page.keyboard.press(width === 400 ? "Home" : "End");
|
||||
}
|
||||
await panel.getByRole("button", { name: "打开 Agent 历史记录" }).click();
|
||||
await page.getByRole("dialog", { name: "历史对话", exact: true }).locator(".agent-history-session-trigger").filter({ hasText: title }).click();
|
||||
const context = panel.locator(".agent-chat-context-bar");
|
||||
const titleElement = context.getByText(title, { exact: true });
|
||||
const header = panel.locator(".agent-panel-header");
|
||||
const topic = header.getByRole("button", { name: "选择主题", exact: true });
|
||||
await expect(topic).toHaveText(topicName);
|
||||
await expect(topic).toHaveAccessibleDescription(topicName);
|
||||
await expect(context.getByRole("button")).toHaveCount(0);
|
||||
await expect(panel.getByText("主题", { exact: true })).toHaveCount(0);
|
||||
await expect(topic.locator("svg.lucide-folder")).toBeVisible();
|
||||
const contextBox = (await context.boundingBox())!;
|
||||
const titleBox = (await titleElement.boundingBox())!;
|
||||
const topicBox = (await topic.boundingBox())!;
|
||||
expect(titleBox.width).toBeGreaterThanOrEqual(contextBox.width - 26);
|
||||
expect(titleBox.height).toBeGreaterThanOrEqual(20);
|
||||
if (width < 640) expect(titleBox.height).toBeGreaterThan(20);
|
||||
expect(titleBox.height).toBeLessThanOrEqual(40);
|
||||
expect(titleBox.y).toBeGreaterThanOrEqual(topicBox.y + topicBox.height);
|
||||
const headerBox = (await header.boundingBox())!;
|
||||
const navigationBox = (await header.locator("..").boundingBox())!;
|
||||
const nameBox = (await header.getByText("供水助手", { exact: true }).boundingBox())!;
|
||||
const statusBox = (await header.getByRole("status").boundingBox())!;
|
||||
const dividerBox = (await panel.locator("[data-agent-header-divider]").boundingBox())!;
|
||||
const actionsBox = (await header.getByRole("group", { name: "对话操作" }).boundingBox())!;
|
||||
expect(statusBox.x).toBeGreaterThanOrEqual(nameBox.x + nameBox.width);
|
||||
await expect(header.getByRole("status")).toHaveText("在线");
|
||||
expect(await header.getByRole("status").evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true);
|
||||
expect(statusBox.x + statusBox.width).toBeLessThanOrEqual(actionsBox.x - 6);
|
||||
expect(Math.abs(statusBox.y + statusBox.height / 2 - nameBox.y - nameBox.height / 2)).toBeLessThanOrEqual(1);
|
||||
expect(topicBox.y).toBeGreaterThanOrEqual(nameBox.y + nameBox.height + 6);
|
||||
expect(dividerBox.height).toBe(1);
|
||||
expect(readColorAlpha(await panel.locator("[data-agent-header-divider]").evaluate((element) => getComputedStyle(element).backgroundColor))).toBeLessThanOrEqual(0.5);
|
||||
await expect(context).toHaveCSS("border-bottom-width", "0px");
|
||||
expect(titleBox.y).toBeGreaterThanOrEqual(dividerBox.y + dividerBox.height + 12);
|
||||
expect(topicBox.x + topicBox.width).toBeLessThanOrEqual(actionsBox.x - 8);
|
||||
expect(contextBox.y + contextBox.height - navigationBox.y).toBeLessThanOrEqual(146);
|
||||
expect(topicBox.x + topicBox.width).toBeLessThanOrEqual(contextBox.x + contextBox.width - 10);
|
||||
expect(await context.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true);
|
||||
await topic.click();
|
||||
const menu = page.getByRole("menu");
|
||||
await expect(menu.getByRole("menuitemradio", { name: topicName, exact: true })).toBeChecked();
|
||||
await expect(menu.getByRole("menuitemradio", { name: "未分类", exact: true })).not.toBeChecked();
|
||||
await expect(page.getByRole("menuitem", { name: "新建主题", exact: true })).toBeVisible();
|
||||
const menuBox = (await menu.boundingBox())!;
|
||||
expect(Math.abs(menuBox.x - topicBox.x)).toBeLessThanOrEqual(1);
|
||||
expect(menuBox.x + menuBox.width).toBeLessThanOrEqual(mobile ? width : 1440);
|
||||
await menu.screenshot({ path: `test-results/agent-context/topic-menu-${width}.png` });
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(topic).toBeFocused();
|
||||
expect(projectHeaders.length).toBeGreaterThan(0);
|
||||
expect(projectHeaders.every((id) => id === TEST_PROJECT_ID)).toBe(true);
|
||||
await testInfo.attach("context-geometry", { contentType: "application/json", body: JSON.stringify({ width, headerBox, navigationBox, contextBox, titleBox, topicBox, statusBox, dividerBox, actionsBox }) });
|
||||
await titleElement.click();
|
||||
await panel.screenshot({ path: `test-results/agent-context/context-${width}.png` });
|
||||
await topic.click();
|
||||
await page.getByRole("menuitemradio", { name: "未分类", exact: true }).click();
|
||||
await expect(topic).toHaveText("未分类");
|
||||
await topic.click();
|
||||
await page.getByRole("menuitemradio", { name: topicName, exact: true }).click();
|
||||
await expect(topic).toHaveText(topicName);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { mockAgentApi } from "./support/mock-agent-api";
|
||||
import { mockWorkbenchContext } from "./support/mock-workbench-context";
|
||||
import { createServer, type ServerResponse } from "node:http";
|
||||
import { responseFixtureWriter } from "./support/responses-fixture";
|
||||
import { expectThemeColor } from "./support/theme-colors";
|
||||
|
||||
async function prepare(page: Page) {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await mockWorkbenchContext(page);
|
||||
await mockAgentApi(page);
|
||||
const counts = { sessions: 0, suggestions: 0, projects: 0 };
|
||||
page.on("request", (request) => {
|
||||
if (request.method() === "OPTIONS") return;
|
||||
const path = new URL(request.url()).pathname;
|
||||
if (path.endsWith("/projects")) counts.projects++;
|
||||
if (path.endsWith("/suggestions")) counts.suggestions++;
|
||||
});
|
||||
const sessions = Array.from({ length: 65 }, (_, index) => ({
|
||||
id: `history-${index}`, session_id: `history-${index}`,
|
||||
title: index === 64 ? "城南供水压力与流量证据核查" : `供水监测复核 ${index + 1}`,
|
||||
updated_at: Date.now() - index * 60_000, created_at: Date.now() - index * 60_000, purpose: "ordinary_chat", run_status: "completed",
|
||||
}));
|
||||
await page.route("**/api/v1/sessions?**", (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback();
|
||||
counts.sessions++;
|
||||
const query = new URL(route.request().url()).searchParams;
|
||||
const filtered = sessions.filter((item) => item.title.includes(query.get("q") ?? ""));
|
||||
const start = Number(query.get("cursor") ?? 0);
|
||||
return route.fulfill({ headers: { "Access-Control-Allow-Origin": "*" }, json: {
|
||||
sessions: filtered.slice(start, start + 30), has_more: start + 30 < filtered.length,
|
||||
next_cursor: start + 30 < filtered.length ? String(start + 30) : null,
|
||||
} });
|
||||
});
|
||||
const messages = Array.from({ length: 90 }, (_, index) => ({ id: `message-${index}`, role: index % 2 ? "assistant" : "user",
|
||||
parts: [{ type: "text", text: `第 ${index + 1} 条:供水监测证据核对。${"压力、流量和监测时窗均需复核。".repeat(5)}` }] }));
|
||||
await page.route(/\/api\/v1\/session\/history-[^?]+(?:\?.*)?$/, (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback();
|
||||
const url = new URL(route.request().url());
|
||||
if (url.pathname.endsWith("/messages")) {
|
||||
const end = Number(url.searchParams.get("before"));
|
||||
const start = Math.max(0, end - 40);
|
||||
return route.fulfill({ headers: { "Access-Control-Allow-Origin": "*" }, json: {
|
||||
messages: messages.slice(start, end), message_page: { has_more: start > 0, before_cursor: start > 0 ? String(start) : null },
|
||||
} });
|
||||
}
|
||||
const id = url.pathname.split("/").at(-1);
|
||||
const session = sessions.find((item) => item.id === id);
|
||||
if (!session) return route.fallback();
|
||||
return route.fulfill({ headers: { "Access-Control-Allow-Origin": "*" }, json: {
|
||||
...session, messages: messages.slice(50), message_page: { has_more: true, before_cursor: "50" },
|
||||
} });
|
||||
});
|
||||
return counts;
|
||||
}
|
||||
|
||||
test("history searches all titles, loads pages and reuses lists and recommendations", async ({ page }, testInfo) => {
|
||||
// Includes ten full focus cycles over 60 retained rows, plus pagination and search.
|
||||
test.setTimeout(60_000);
|
||||
// Keep the activity snapshot stable while testing cache hits; real status changes have unit coverage.
|
||||
await page.clock.setFixedTime(new Date());
|
||||
const counts = await prepare(page);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
|
||||
const trigger = page.getByRole("button", { name: "打开 Agent 历史记录" });
|
||||
await expect(page.getByRole("group", { name: "推荐问题" })).toHaveAttribute("aria-busy", "false");
|
||||
await expect(page.getByRole("button", { name: "选择 Agent 模型" }).locator("svg.lucide-zap")).toBeVisible();
|
||||
const baseline = { ...counts };
|
||||
await trigger.click();
|
||||
const dialog = page.getByRole("dialog", { name: "历史对话" });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByRole("searchbox")).toBeFocused();
|
||||
await expectThemeColor(dialog, "background-color", "--surface-reading");
|
||||
await expect(dialog).toHaveCSS("background-color", "rgb(255, 255, 255)");
|
||||
await expect(dialog.locator("[data-agent-history-header]")).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
|
||||
await expect(dialog.getByRole("region", { name: "Agent 历史记录" })).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
|
||||
await expect(page.locator(".agent-history-overlay")).toHaveCSS("backdrop-filter", "none");
|
||||
await expect(dialog.getByRole("button", { name: "关闭历史对话" }).locator("svg.lucide-x")).toHaveCSS("width", "16px");
|
||||
await expect(dialog.locator(".agent-history-session-trigger")).toHaveCount(30);
|
||||
await dialog.getByRole("button", { name: "加载更多", exact: true }).click();
|
||||
await expect(dialog.locator(".agent-history-session-trigger")).toHaveCount(60);
|
||||
await dialog.getByRole("searchbox").fill("城南");
|
||||
await expect(dialog.locator(".agent-history-session-trigger")).toHaveCount(1);
|
||||
await expect(dialog.getByText("城南供水压力与流量证据核查", { exact: true })).toBeVisible();
|
||||
await dialog.getByRole("searchbox").fill("");
|
||||
await expect(dialog.locator(".agent-history-session-trigger")).toHaveCount(60);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(trigger).toBeFocused();
|
||||
const loadedCounts = { ...counts };
|
||||
const cycleMs: number[] = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const started = Date.now();
|
||||
await trigger.click();
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.locator(".agent-history-session-trigger")).toHaveCount(60);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(trigger).toBeFocused();
|
||||
cycleMs.push(Date.now() - started);
|
||||
}
|
||||
await testInfo.attach("history-cache-metrics", { contentType: "application/json", body: JSON.stringify({ before: loadedCounts, after: counts, cycleMs }) });
|
||||
expect(counts).toEqual(loadedCounts);
|
||||
expect(counts.suggestions).toBe(baseline.suggestions);
|
||||
expect(counts.projects).toBe(baseline.projects);
|
||||
await trigger.click();
|
||||
await dialog.screenshot({ path: "test-results/agent-history/desktop.png" });
|
||||
});
|
||||
|
||||
for (const width of [320, 375]) test(`history is full screen and preserves the mobile sheet at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 812 });
|
||||
await prepare(page);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "打开 Agent 面板" }).click();
|
||||
await page.getByRole("button", { name: "打开 Agent 历史记录" }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "历史对话" });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog).toHaveCSS("background-color", "rgb(255, 255, 255)");
|
||||
const box = (await dialog.boundingBox())!;
|
||||
expect(box.x).toBe(0); expect(box.y).toBe(0); expect(box.width).toBe(width);
|
||||
expect(await dialog.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true);
|
||||
await dialog.screenshot({ path: `test-results/agent-history/mobile-${width}.png` });
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(window.visualViewport!, "height", { configurable: true, value: 480 });
|
||||
window.visualViewport!.dispatchEvent(new Event("resize"));
|
||||
});
|
||||
await expect.poll(async () => (await dialog.boundingBox())!.height).toBe(480);
|
||||
await expect(dialog.getByRole("button", { name: "关闭历史对话" })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(page.getByRole("region", { name: "Agent 工作台抽屉" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("older messages prepend without moving the visible reading anchor", async ({ page }) => {
|
||||
await prepare(page);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
|
||||
await page.getByRole("button", { name: "打开 Agent 历史记录" }).click();
|
||||
await page.getByRole("dialog", { name: "历史对话" }).locator(".agent-history-session-trigger").first().click();
|
||||
await expect(page.getByRole("dialog", { name: "历史对话" })).toBeHidden();
|
||||
await expect(page.locator("[data-agent-message-id]")).toHaveCount(40);
|
||||
const scroll = page.locator(".agent-conversation-scroll");
|
||||
await scroll.evaluate((element) => { element.scrollTop = 1; element.dispatchEvent(new Event("scroll")); });
|
||||
await expect(page.locator("[data-agent-message-id]")).toHaveCount(80);
|
||||
expect(await scroll.evaluate((element) => element.scrollTop)).toBeGreaterThan(100);
|
||||
await expect(page.getByRole("button", { name: "滚动到最新消息" })).toBeVisible();
|
||||
const position = await scroll.evaluate((element) => element.scrollTop);
|
||||
await page.getByRole("button", { name: "打开 Agent 历史记录" }).click();
|
||||
await page.getByRole("dialog", { name: "历史对话" }).getByRole("button", { name: /供水监测复核 2\s/ }).click();
|
||||
await expect(page.locator("[data-agent-message-id]")).toHaveCount(40);
|
||||
await page.getByRole("button", { name: "打开 Agent 历史记录" }).click();
|
||||
await page.getByRole("dialog", { name: "历史对话" }).getByRole("button", { name: /供水监测复核 1\s/ }).click();
|
||||
await expect(page.locator("[data-agent-message-id]")).toHaveCount(80);
|
||||
await expect.poll(async () => Math.abs(await scroll.evaluate((element) => element.scrollTop) - position)).toBeLessThan(2);
|
||||
});
|
||||
|
||||
test("switching conversations keeps the original generation and each draft isolated", async ({ page }) => {
|
||||
await prepare(page);
|
||||
let output: ServerResponse | undefined;
|
||||
let aborts = 0;
|
||||
let readsOfRunningSession = 0;
|
||||
const write = responseFixtureWriter("background-a");
|
||||
const server = createServer((_request, response) => {
|
||||
output = response;
|
||||
response.writeHead(200, { "Access-Control-Allow-Origin": "*", "Content-Type": "text/event-stream", "Cache-Control": "no-cache" });
|
||||
response.write(write({ type: "start", messageId: "background-answer" }));
|
||||
response.write(write({ type: "data-progress", data: { session_id: "background-a", title: "正在复核后台压力", status: "running" } }));
|
||||
response.write(write({ type: "data-session_title", data: { session_id: "background-a", title: "后台压力复核 A" } }));
|
||||
response.write(write({ type: "text-start", id: "background-text" }));
|
||||
response.write(write({ type: "text-delta", id: "background-text", delta: "已开始复核," }));
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("missing fixture server address");
|
||||
try {
|
||||
await page.route("**/api/v1/responses", (route) => route.request().method() === "OPTIONS" ? route.fallback()
|
||||
: route.continue({ url: `http://127.0.0.1:${address.port}/responses` }));
|
||||
page.on("request", (request) => {
|
||||
if (request.method() === "OPTIONS") return;
|
||||
const path = new URL(request.url()).pathname;
|
||||
if (path.endsWith("/abort")) aborts++;
|
||||
if (path.endsWith("/session/background-a")) readsOfRunningSession++;
|
||||
});
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
|
||||
const prompt = page.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" });
|
||||
await prompt.fill("核对后台压力");
|
||||
await page.getByRole("button", { name: "发送 Agent 指令", exact: true }).click();
|
||||
await expect(page.getByText("已开始复核,", { exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "打开 Agent 历史记录" }).click();
|
||||
await page.getByRole("dialog", { name: "历史对话" }).getByRole("button", { name: /供水监测复核 1\s/ }).click();
|
||||
await prompt.fill("保留在另一会话的草稿");
|
||||
await expect(page.getByRole("button", { name: /会话正在后台处理/ })).toBeVisible();
|
||||
output!.write(write({ type: "text-delta", id: "background-text", delta: "后台证据核对完成。" }));
|
||||
output!.write(write({ type: "text-end", id: "background-text" }));
|
||||
output!.write(write({ type: "finish" }));
|
||||
output!.end();
|
||||
await page.getByRole("button", { name: "打开 Agent 历史记录" }).click();
|
||||
await page.getByRole("dialog", { name: "历史对话" }).getByRole("button", { name: /后台压力复核 A\s/ }).click();
|
||||
await expect(page.getByText("已开始复核,后台证据核对完成。", { exact: true })).toBeVisible();
|
||||
await expect(prompt).toHaveValue("");
|
||||
expect(aborts).toBe(0);
|
||||
expect(readsOfRunningSession).toBe(0);
|
||||
await page.getByRole("button", { name: "打开 Agent 历史记录" }).click();
|
||||
await page.getByRole("dialog", { name: "历史对话" }).getByRole("button", { name: /供水监测复核 1\s/ }).click();
|
||||
await expect(prompt).toHaveValue("保留在另一会话的草稿");
|
||||
} finally {
|
||||
output?.end();
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,328 @@
|
||||
import { expect, test, type Locator } from "@playwright/test";
|
||||
import { mockAgentApi } from "./support/mock-agent-api";
|
||||
import { mockWorkbenchContext } from "./support/mock-workbench-context";
|
||||
import { responsesFixture } from "./support/responses-fixture";
|
||||
|
||||
test.use({ launchOptions: { args: ["--enable-unsafe-swiftshader"] } });
|
||||
|
||||
async function ready(orb: Locator) {
|
||||
await expect(orb.locator('.agent-orb-live[data-ready="true"]')).toBeVisible({ timeout: 30000 });
|
||||
await expect(orb.locator("canvas")).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectNoPressFeedback(orb: Locator) {
|
||||
await expect(orb).toHaveCSS("opacity", "1");
|
||||
await expect(orb.locator(".agent-orb-response")).toHaveCSS("transform", "none");
|
||||
}
|
||||
|
||||
async function expectMoving(orb: Locator) {
|
||||
const first = await orb.locator("canvas").screenshot();
|
||||
await expect
|
||||
.poll(async () => !(await orb.locator("canvas").screenshot()).equals(first))
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockWorkbenchContext(page);
|
||||
await mockAgentApi(page);
|
||||
});
|
||||
|
||||
test("first load blends the original still into the animation without a visual cut", async ({ page }) => {
|
||||
let release!: () => void;
|
||||
const downloadReady = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await page.route("**/obsidian-water-v1.riv*", async (route) => {
|
||||
await downloadReady;
|
||||
await route.continue();
|
||||
});
|
||||
try {
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
const orb = page.getByLabel("Agent 折叠栏").locator("[data-agent-orb]");
|
||||
await expect(orb.locator(".agent-orb-canvas")).toHaveCount(1);
|
||||
await expect(orb.locator("img")).toBeVisible();
|
||||
await expect(orb.locator("img")).toHaveAttribute("src", /obsidian-water-idle\.png\?v=/);
|
||||
const transition = orb.evaluate((element) => new Promise<{
|
||||
opacity: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}[]>((resolve, reject) => {
|
||||
const frames: { opacity: number; width: number; height: number }[] = [];
|
||||
const start = performance.now();
|
||||
const sample = () => {
|
||||
const canvas = element.querySelector(".agent-orb-canvas")!;
|
||||
const opacity = Number(getComputedStyle(canvas).opacity);
|
||||
const { width, height } = element.getBoundingClientRect();
|
||||
frames.push({ opacity, width, height });
|
||||
if (opacity === 1) return resolve(frames);
|
||||
if (performance.now() - start > 30000) return reject(new Error("Animation did not appear"));
|
||||
requestAnimationFrame(sample);
|
||||
};
|
||||
sample();
|
||||
}));
|
||||
release();
|
||||
const frames = await transition;
|
||||
expect(frames[0].opacity).toBe(0);
|
||||
expect(frames.some(({ opacity }) => opacity > 0 && opacity < 1)).toBe(true);
|
||||
expect(frames.every(({ width, height }) => width === 64 && height === 64)).toBe(true);
|
||||
await expect(orb.locator("img")).toBeHidden();
|
||||
await expectMoving(orb);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
});
|
||||
|
||||
test("renders original Obsidian, caches across reloads and releases offscreen renderers", async ({
|
||||
page
|
||||
}) => {
|
||||
const resources: string[] = [];
|
||||
page.on("request", (request) => {
|
||||
if (/\.riv(?:\?|$)/.test(request.url())) resources.push(request.url());
|
||||
});
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
const launcher = page.getByLabel("Agent 折叠栏");
|
||||
const orb = launcher.locator("[data-agent-orb]");
|
||||
await expect(orb).toHaveAttribute("data-agent-orb", "idle", { timeout: 30000 });
|
||||
await ready(orb);
|
||||
await expect(orb.locator(".agent-orb-canvas")).toHaveCSS("filter", "none");
|
||||
await expectMoving(orb);
|
||||
await orb.hover();
|
||||
await expect(orb).toHaveAttribute("data-hovered", "true");
|
||||
await page.mouse.move(600, 600);
|
||||
await expect(orb).toHaveAttribute("data-hovered", "false");
|
||||
await orb.evaluate((element) => {
|
||||
(element.parentElement as HTMLElement).style.transform = "translateX(-2000px)";
|
||||
});
|
||||
await expect(orb.locator("canvas")).toHaveCount(0);
|
||||
await orb.evaluate((element) => {
|
||||
(element.parentElement as HTMLElement).style.transform = "";
|
||||
});
|
||||
await ready(orb);
|
||||
await launcher.getByRole("button").click();
|
||||
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
|
||||
await ready(panel.locator("header [data-agent-orb]"));
|
||||
await expect(panel.locator("header [data-agent-orb]")).toHaveCSS("width", "64px");
|
||||
await expect(page.locator(".agent-orb canvas")).toHaveCount(1);
|
||||
await panel.getByRole("button", { name: "折叠 Agent 面板" }).click();
|
||||
await ready(orb);
|
||||
expect(resources.map((url) => new URL(url).pathname)).toEqual(["/agent/obsidian-water-v1.riv"]);
|
||||
// A full reload loses module memory. Block the asset network path to prove the
|
||||
// next live renderer reads persistent Cache Storage rather than downloading.
|
||||
await page.route("**/obsidian-water-v1.riv*", (route) => route.abort());
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await ready(orb);
|
||||
await expectMoving(orb);
|
||||
expect(resources).toHaveLength(1);
|
||||
await page.screenshot({
|
||||
path: "/tmp/nexttjwater-obsidian-restored/workbench.png",
|
||||
clip: { x: 0, y: 56, width: 340, height: 190 }
|
||||
});
|
||||
});
|
||||
|
||||
test("pointer coordinates reach the native hover listener in the launcher and header", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
const addListener = HTMLCanvasElement.prototype.addEventListener;
|
||||
HTMLCanvasElement.prototype.addEventListener = function (type, listener, options) {
|
||||
if (type === "mousemove") {
|
||||
// Observe delivery to Rive's native canvas event path. A hover boolean
|
||||
// alone leaves the resource's pointer target at the sphere center.
|
||||
addListener.call(this, type, (event) => {
|
||||
const { clientX, clientY } = event as MouseEvent;
|
||||
const bounds = this.getBoundingClientRect();
|
||||
this.dataset.pointerPosition = JSON.stringify({
|
||||
x: (clientX - bounds.left) / bounds.width,
|
||||
y: (clientY - bounds.top) / bounds.height
|
||||
});
|
||||
});
|
||||
}
|
||||
addListener.call(this, type, listener, options);
|
||||
};
|
||||
});
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
const launcher = page.getByLabel("Agent 折叠栏");
|
||||
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
|
||||
for (const orb of [launcher.locator("[data-agent-orb]"), panel.locator("header [data-agent-orb]")]) {
|
||||
await ready(orb);
|
||||
const bounds = (await orb.boundingBox())!;
|
||||
for (const x of [0.25, 0.75, 0.5]) {
|
||||
await page.mouse.move(bounds.x + bounds.width * x, bounds.y + bounds.height * 0.5);
|
||||
await expect.poll(async () => {
|
||||
const point = await orb.locator("canvas").getAttribute("data-pointer-position");
|
||||
return point ? JSON.parse(point) : null;
|
||||
}).toEqual({ x, y: 0.5 });
|
||||
}
|
||||
await page.mouse.move(600, 600);
|
||||
await expect(orb).toHaveAttribute("data-hovered", "false");
|
||||
if (!(await panel.isVisible())) await launcher.getByRole("button").click();
|
||||
}
|
||||
});
|
||||
|
||||
test("mouse presses preserve size and brightness while clicking still opens the Agent", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
const orb = page.getByLabel("Agent 折叠栏").locator("[data-agent-orb]");
|
||||
await ready(orb);
|
||||
await orb.hover();
|
||||
await page.mouse.down();
|
||||
await expectNoPressFeedback(orb);
|
||||
await expect(orb).toHaveAttribute("data-hovered", "true");
|
||||
await page.mouse.move(600, 600);
|
||||
await page.mouse.up();
|
||||
await expectNoPressFeedback(orb);
|
||||
await expect(orb).toHaveAttribute("data-hovered", "false");
|
||||
await orb.click();
|
||||
await expect(page.getByRole("complementary", { name: "Agent 命令面板" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("follows processing and completion while the Agent is collapsed", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||
let release!: () => void;
|
||||
const responseReady = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await page.route("**/api/v1/responses", async (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback();
|
||||
await responseReady;
|
||||
await route.fulfill({
|
||||
headers: { "Access-Control-Allow-Origin": "*", "Content-Type": "text/event-stream" },
|
||||
body: responsesFixture([
|
||||
{ type: "start", messageId: "orb-state-preview" },
|
||||
{ type: "text-start", id: "answer" },
|
||||
{ type: "text-delta", id: "answer", delta: "动态球状态演练回复。" },
|
||||
{ type: "text-end", id: "answer" },
|
||||
{ type: "finish", finishReason: "stop" }
|
||||
])
|
||||
});
|
||||
});
|
||||
try {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
|
||||
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
|
||||
await panel.locator("textarea").fill("检查当前供水状态");
|
||||
await panel.getByRole("button", { name: "发送 Agent 指令" }).click();
|
||||
await panel.getByRole("button", { name: "折叠 Agent 面板" }).click();
|
||||
const orb = page.getByLabel("Agent 折叠栏").locator("[data-agent-orb]");
|
||||
await expect(orb).toHaveAttribute("data-agent-orb", "thinking");
|
||||
await ready(orb);
|
||||
await expectMoving(orb);
|
||||
release();
|
||||
await expect(orb).toHaveAttribute("data-agent-orb", "idle");
|
||||
await expectMoving(orb);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
});
|
||||
|
||||
test("offline plays the resource water loops without press feedback and opens the Agent", async ({
|
||||
page
|
||||
}) => {
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||
await page.route("**/api/v1/models", async (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback();
|
||||
await route.fulfill({
|
||||
status: 503,
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
json: { error: "演练连接失败" }
|
||||
});
|
||||
});
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
const launcher = page.getByLabel("Agent 折叠栏");
|
||||
const orb = launcher.locator("[data-agent-orb]");
|
||||
await expect(orb).toHaveAttribute("data-agent-orb", "asleep", { timeout: 30000 });
|
||||
await ready(orb);
|
||||
await expect(orb.locator("img")).toBeHidden();
|
||||
await expectMoving(orb);
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await expect(orb.locator("canvas")).toHaveCount(0);
|
||||
await expect(orb.locator("img")).toBeVisible();
|
||||
await expect(orb.locator("img")).toHaveAttribute("src", /obsidian-water-offline\.png\?v=/);
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||
await ready(orb);
|
||||
await orb.hover();
|
||||
await page.mouse.down();
|
||||
await expectNoPressFeedback(orb);
|
||||
await page.mouse.move(600, 600);
|
||||
await page.mouse.up();
|
||||
await expectNoPressFeedback(orb);
|
||||
await page.screenshot({
|
||||
path: "/tmp/nexttjwater-obsidian-restored/offline.png",
|
||||
clip: { x: 0, y: 56, width: 340, height: 190 }
|
||||
});
|
||||
await launcher.getByRole("button").click();
|
||||
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
|
||||
await ready(panel.locator("header [data-agent-orb]"));
|
||||
await expect(panel.locator("header [data-agent-orb]")).toHaveAttribute("data-agent-orb", "asleep");
|
||||
await expect(page.locator(".agent-orb canvas")).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("reduced motion uses a still and keeps keyboard access in forced colors", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
const launcher = page.getByLabel("Agent 折叠栏");
|
||||
const orb = launcher.locator("[data-agent-orb]");
|
||||
await expect(orb).toHaveAttribute("data-agent-orb", "idle", { timeout: 30000 });
|
||||
await expect(orb.locator("canvas")).toHaveCount(0);
|
||||
await orb.hover();
|
||||
await page.mouse.down();
|
||||
await expectNoPressFeedback(orb);
|
||||
await page.mouse.move(600, 600);
|
||||
await page.mouse.up();
|
||||
await expect(orb).toHaveCSS("opacity", "1");
|
||||
await page.emulateMedia({ reducedMotion: "reduce", forcedColors: "active" });
|
||||
await expect(orb.locator(".agent-orb-contrast")).toBeVisible();
|
||||
await expect(orb.locator(".agent-orb-contrast")).toHaveCSS("border-top-color", "rgb(0, 0, 0)");
|
||||
await launcher.getByRole("button").focus();
|
||||
await page.keyboard.press("Space");
|
||||
await expect(page.getByRole("complementary", { name: "Agent 命令面板" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("animation download failure retains the original still and the working launcher", async ({
|
||||
page
|
||||
}) => {
|
||||
await page.route("**/obsidian-water-v1.riv*", (route) => route.abort());
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
const launcher = page.getByLabel("Agent 折叠栏");
|
||||
await expect(launcher.locator('.agent-orb-live[data-failed="true"]')).toBeVisible({
|
||||
timeout: 30000
|
||||
});
|
||||
await expect(launcher.locator("img")).toBeVisible();
|
||||
await expect(launcher.locator("canvas")).toHaveCount(0);
|
||||
await launcher.getByRole("button").click();
|
||||
await expect(page.getByRole("complementary", { name: "Agent 命令面板" })).toBeVisible();
|
||||
});
|
||||
|
||||
test.describe("touch interaction", () => {
|
||||
test.use({ hasTouch: true, viewport: { width: 375, height: 812 } });
|
||||
test("mobile presses add no animation and tapping opens the Agent", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
const button = page.getByRole("button", { name: "打开 Agent 面板", exact: true });
|
||||
const orb = button.locator("[data-agent-orb]");
|
||||
await ready(orb);
|
||||
await expect(orb).toHaveCSS("width", "40px");
|
||||
const bounds = (await orb.boundingBox())!;
|
||||
const touch = await page.context().newCDPSession(page);
|
||||
await touch.send("Input.dispatchTouchEvent", {
|
||||
type: "touchStart",
|
||||
touchPoints: [{ x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 }]
|
||||
});
|
||||
await expectNoPressFeedback(orb);
|
||||
await expect(orb).toHaveAttribute("data-hovered", "false");
|
||||
await touch.send("Input.dispatchTouchEvent", { type: "touchCancel", touchPoints: [] });
|
||||
await expectNoPressFeedback(orb);
|
||||
await expect(orb).toHaveAttribute("data-hovered", "false");
|
||||
await button.tap();
|
||||
await expect(page.getByRole("complementary", { name: "Agent 命令面板" })).toBeVisible();
|
||||
await ready(page.locator('[aria-label="Agent 命令面板"] header [data-agent-orb]'));
|
||||
await expect(page.locator(".agent-orb canvas")).toHaveCount(1);
|
||||
await page.screenshot({ path: "/tmp/nexttjwater-orb-loading/mobile-375.png" });
|
||||
await page.setViewportSize({ width: 320, height: 812 });
|
||||
await expect(page.locator('[aria-label="Agent 命令面板"] header [data-agent-orb]')).toHaveCSS("width", "64px");
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
||||
await page.screenshot({ path: "/tmp/nexttjwater-orb-loading/mobile-320.png" });
|
||||
await touch.detach();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test";
|
||||
import { mockAgentApi } from "./support/mock-agent-api";
|
||||
import { mockWorkbenchContext } from "./support/mock-workbench-context";
|
||||
|
||||
@@ -6,12 +6,17 @@ test.beforeEach(async ({ page }) => {
|
||||
await mockAgentApi(page);
|
||||
});
|
||||
|
||||
async function expectPanelWidth(panel: Locator, width: number) {
|
||||
await expect.poll(async () => Math.round((await panel.boundingBox())!.width)).toBe(width);
|
||||
}
|
||||
|
||||
async function openAgentPanel(page: Page) {
|
||||
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
|
||||
await expect(page.getByRole("complementary", { name: "Agent 命令面板" })).toBeVisible();
|
||||
}
|
||||
|
||||
test("the docked Agent collapses and restores the main workspace", async ({ page }) => {
|
||||
await mockWorkbenchContext(page);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await openAgentPanel(page);
|
||||
|
||||
@@ -27,10 +32,44 @@ test("the docked Agent collapses and restores the main workspace", async ({ page
|
||||
|
||||
await page.getByRole("button", { name: "折叠 Agent 面板" }).click();
|
||||
await expect(page.getByLabel("Agent 折叠栏")).toBeVisible();
|
||||
await expect(page.getByLabel("Agent 折叠栏")).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
|
||||
await expect(page.getByLabel("Agent 折叠栏").getByLabel("Agent 状态")).toBeVisible();
|
||||
for (const width of [1440, 1024]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
const rail = page.getByLabel("Agent 折叠栏");
|
||||
await expectPanelWidth(rail, 64);
|
||||
await expect.poll(async () => {
|
||||
const [orb, header] = await Promise.all([
|
||||
rail.boundingBox(),
|
||||
page.locator(".workbench-top-bar").boundingBox()
|
||||
]);
|
||||
return {
|
||||
left: Math.round(orb!.x),
|
||||
top: Math.round(orb!.y - header!.y - header!.height),
|
||||
height: Math.round(orb!.height)
|
||||
};
|
||||
}).toEqual({ left: 12, top: 12, height: 64 });
|
||||
}
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.getByLabel("Agent 折叠栏").screenshot({ path: "/tmp/nexttjwater-agent-start-preview/collapsed-persona.png" });
|
||||
await page.screenshot({ path: "/tmp/nexttjwater-obsidian-orb/workbench.png", clip: { x: 0, y: 56, width: 340, height: 190 } });
|
||||
await expect.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0)).toBe(0);
|
||||
|
||||
await page.getByLabel(/展开 Agent 助手面板/).click();
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
await cdp.send("Emulation.setEmulatedMedia", {
|
||||
features: [{ name: "prefers-reduced-transparency", value: "reduce" }]
|
||||
});
|
||||
await expect(page.getByLabel("Agent 折叠栏")).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
|
||||
await expect(page.getByLabel("Agent 折叠栏")).toHaveCSS("backdrop-filter", "none");
|
||||
await cdp.detach();
|
||||
|
||||
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).focus();
|
||||
await page.keyboard.press("Shift+Tab");
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(page.getByRole("button", { name: /展开 Agent 助手面板/ })).toBeFocused();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(agentPanel).toBeVisible();
|
||||
await expect(agentPanel.locator("header").getByLabel("Agent 状态")).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0))
|
||||
.toBe(expandedOffset);
|
||||
@@ -46,41 +85,42 @@ test("dragging the Agent edge keeps the workspace attached and preserves the dra
|
||||
const prompt = panel.getByPlaceholder("询问供水问题,或描述需要处理的任务");
|
||||
await expect(panel.getByText("当前上下文", { exact: true })).toHaveCount(0);
|
||||
await prompt.fill("保留尚未发送的诊断问题");
|
||||
await expect(panel).toHaveCSS("width", "500px");
|
||||
await expectPanelWidth(panel, 500);
|
||||
await page.waitForFunction(() =>
|
||||
Boolean(window.__waterNetworkMap?.getLayer("scada-pressure-hit")) &&
|
||||
!window.__waterNetworkMap?.isMoving()
|
||||
);
|
||||
const cameraBefore = await readCamera(page);
|
||||
expect(await handle.evaluate((element) => (element as HTMLElement).tabIndex)).toBe(-1);
|
||||
const grip = handle.locator(".agent-resize-grip");
|
||||
await expect(grip).toHaveCSS("opacity", "0");
|
||||
expect(await handle.evaluate((element) => (element as HTMLElement).tabIndex)).toBe(0);
|
||||
await expect(handle).toHaveAttribute("data-slot", "resizable-handle");
|
||||
const grip = handle.locator(".resizable-grip");
|
||||
await expect(grip).toBeVisible();
|
||||
const box = (await handle.boundingBox())!;
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await expect(grip).toHaveCSS("opacity", "1");
|
||||
await expect(handle).toHaveAttribute("data-separator", "hover");
|
||||
await page.screenshot({ path: "/tmp/nexttjwater-agent-resize-hover.png" });
|
||||
await page.mouse.down();
|
||||
await expect(handle).toHaveAttribute("data-resizing", "true");
|
||||
await expect(panel).toHaveCSS("width", "500px");
|
||||
await expect(handle).toHaveAttribute("data-separator", "active");
|
||||
await expectPanelWidth(panel, 500);
|
||||
for (const nextWidth of [540, 580, 620]) {
|
||||
await page.mouse.move(nextWidth, box.y + box.height / 2);
|
||||
await expect(panel).toHaveCSS("width", `${nextWidth}px`);
|
||||
await expectPanelWidth(panel, nextWidth);
|
||||
const panelBox = (await panel.boundingBox())!;
|
||||
const workspaceBox = (await workspace.boundingBox())!;
|
||||
expect(workspaceBox.x).toBe(panelBox.x + panelBox.width);
|
||||
}
|
||||
await page.screenshot({ path: "test-results/agent-history-ui/desktop-resizing.png" });
|
||||
await page.mouse.up();
|
||||
await expect(handle).toHaveAttribute("data-resizing", "false");
|
||||
await expect(handle).not.toHaveAttribute("data-separator", "active");
|
||||
await expect(prompt).toHaveValue("保留尚未发送的诊断问题");
|
||||
await expect.poll(() => readCamera(page)).toEqual(cameraBefore);
|
||||
await page.mouse.move(1000, 450);
|
||||
await expect(grip).toHaveCSS("opacity", "0");
|
||||
await expect(panel).toHaveCSS("width", "620px");
|
||||
await expect(grip).toBeVisible();
|
||||
await expectPanelWidth(panel, 620);
|
||||
await page.getByRole("button", { name: "折叠 Agent 面板" }).click();
|
||||
await expect(page.getByLabel("Agent 折叠栏")).toBeVisible();
|
||||
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
|
||||
await expect(panel).toHaveCSS("width", "620px");
|
||||
await expectPanelWidth(panel, 620);
|
||||
await expect(prompt).toHaveValue("保留尚未发送的诊断问题");
|
||||
});
|
||||
|
||||
@@ -96,6 +136,34 @@ async function readCamera(page: Page) {
|
||||
});
|
||||
}
|
||||
|
||||
test("Agent Resizable supports keyboard limits and preserves its width across mobile", async ({ page }) => {
|
||||
await mockWorkbenchContext(page);
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await openAgentPanel(page);
|
||||
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
|
||||
const handle = page.getByRole("separator", { name: "调整 Agent 面板宽度" });
|
||||
await panel.locator("textarea").fill("键盘调整后仍保留的草稿");
|
||||
await page.waitForFunction(() => Boolean(window.__waterNetworkMap));
|
||||
await page.evaluate(() => Object.assign(window, { originalResizeMap: window.__waterNetworkMap }));
|
||||
await handle.focus();
|
||||
await expect(handle).toBeFocused();
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await expect(handle.locator(".resizable-grip")).toHaveCSS("outline-style", "solid");
|
||||
await expect.poll(async () => (await panel.boundingBox())!.width).toBeGreaterThan(500);
|
||||
await page.keyboard.press("Home");
|
||||
await expectPanelWidth(panel, 400);
|
||||
await page.keyboard.press("End");
|
||||
await expectPanelWidth(panel, 640);
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
await expect(handle).toBeHidden();
|
||||
await expect.poll(async () => (await page.locator("#main-workspace").boundingBox())!.width).toBe(375);
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await expectPanelWidth(panel, 640);
|
||||
await expect(panel.locator("textarea")).toHaveValue("键盘调整后仍保留的草稿");
|
||||
expect(await page.evaluate(() => window.__waterNetworkMap === (window as unknown as { originalResizeMap: unknown }).originalResizeMap)).toBe(true);
|
||||
});
|
||||
|
||||
test("Agent dragging stays within usable widths and adapts to the viewport", async ({ page }) => {
|
||||
await mockWorkbenchContext(page);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
@@ -109,18 +177,18 @@ test("Agent dragging stays within usable widths and adapts to the viewport", asy
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(targetX, box.y + box.height / 2);
|
||||
await page.mouse.up();
|
||||
await expect(panel).toHaveCSS("width", `${expectedWidth}px`);
|
||||
await expect.poll(async () => (await workspace.boundingBox())?.x).toBe(expectedWidth);
|
||||
await expectPanelWidth(panel, expectedWidth);
|
||||
await expect.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0)).toBe(expectedWidth);
|
||||
if (expectedWidth === 400) {
|
||||
await page.screenshot({ path: "test-results/agent-history-ui/desktop-resize-minimum.png" });
|
||||
}
|
||||
}
|
||||
await page.setViewportSize({ width: 1024, height: 768 });
|
||||
await expect(panel).toHaveCSS("width", "500px");
|
||||
await expect.poll(async () => (await workspace.boundingBox())?.x).toBe(500);
|
||||
await expectPanelWidth(panel, 500);
|
||||
await expect.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0)).toBe(500);
|
||||
await page.setViewportSize({ width: 1920, height: 1080 });
|
||||
await expect(panel).toHaveCSS("width", "620px");
|
||||
await expect.poll(async () => (await workspace.boundingBox())?.x).toBe(620);
|
||||
await expectPanelWidth(panel, 620);
|
||||
await expect.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0)).toBe(620);
|
||||
});
|
||||
|
||||
test("the map stays painted while the Agent and viewport change size", async ({ page }) => {
|
||||
@@ -222,6 +290,7 @@ test("the map canvas follows workspace resizing before the next paint", async ({
|
||||
|
||||
test("the floating condition feed coordinates with the docked Agent", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await mockWorkbenchContext(page);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await openAgentPanel(page);
|
||||
|
||||
@@ -265,6 +334,7 @@ test.describe("mobile touch layout", () => {
|
||||
test.use({ hasTouch: true, isMobile: true, viewport: { width: 375, height: 812 } });
|
||||
|
||||
test("mobile Agent toggle opens the conversation panel by touch", async ({ page }) => {
|
||||
await mockWorkbenchContext(page);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const openButton = page.getByRole("button", { name: "打开 Agent 面板" });
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import { build } from "esbuild";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { buildMaterialPersona, buildOfflinePersona, encodeSource } from "../../scripts/build-agent-persona.mjs";
|
||||
|
||||
test.use({ launchOptions: { args: ["--enable-unsafe-swiftshader"] } });
|
||||
|
||||
const harness = `import { RuntimeLoader } from '@rive-app/react-webgl2';
|
||||
RuntimeLoader.setWasmUrl('/rive-probe/runtime.wasm');
|
||||
window.ready = new Promise(resolve => RuntimeLoader.getInstance(resolve)).then(runtime => {
|
||||
window.renderResource = async (version, steps) => {
|
||||
const bytes = new Uint8Array(await (await fetch('/rive-probe/' + version + '.riv')).arrayBuffer());
|
||||
const file = await runtime.load(bytes, undefined, false);
|
||||
const artboard = file.defaultArtboard();
|
||||
const machine = new runtime.StateMachineInstance(artboard.stateMachineByName('default'), artboard);
|
||||
const inputs = {};
|
||||
for (let i = 0; i < machine.inputCount(); i++) {
|
||||
const input = machine.input(i);
|
||||
inputs[input.name] = input.asBool();
|
||||
}
|
||||
const canvas = document.querySelector('canvas');
|
||||
const renderer = runtime.makeRenderer(canvas, true);
|
||||
const copy = document.createElement('canvas');
|
||||
copy.width = copy.height = 192;
|
||||
const context = copy.getContext('2d');
|
||||
try {
|
||||
const snapshots = [];
|
||||
for (const step of steps) {
|
||||
for (const name in inputs) inputs[name].value = step.state === name || (name === 'hover' && !!step.hovered);
|
||||
if (step.pointer) machine.pointerMove(step.pointer[0], step.pointer[1], 0);
|
||||
for (let frame = 0; frame < step.frames; frame++) {
|
||||
machine.advance(1 / 60);
|
||||
artboard.advance(1 / 60);
|
||||
}
|
||||
renderer.clear();
|
||||
renderer.save();
|
||||
renderer.align(runtime.Fit.contain, runtime.Alignment.center,
|
||||
{ minX: 0, minY: 0, maxX: 192, maxY: 192 }, artboard.bounds);
|
||||
artboard.draw(renderer);
|
||||
renderer.restore();
|
||||
renderer.flush();
|
||||
runtime.resolveAnimationFrame();
|
||||
context.clearRect(0, 0, 192, 192);
|
||||
context.drawImage(canvas, 0, 0);
|
||||
const pixels = context.getImageData(0, 0, 192, 192).data;
|
||||
let visiblePixels = 0, bluePixels = 0, outerPixels = 0, compositedBluePixels = 0;
|
||||
let minimumBodyAlpha = 255;
|
||||
for (let i = 0; i < pixels.length; i += 4) {
|
||||
const x = (i / 4) % 192 + 0.5 - 96;
|
||||
const y = Math.floor(i / 4 / 192) + 0.5 - 96;
|
||||
// Sample inside the feathered rim: map lines should not remain visible
|
||||
// through the body's center in either online or offline states.
|
||||
if (Math.hypot(x, y) < 50) minimumBodyAlpha = Math.min(minimumBodyAlpha, pixels[i + 3]);
|
||||
if (pixels[i + 3] <= 32) continue;
|
||||
visiblePixels++;
|
||||
if (pixels[i + 2] > pixels[i] + 20 && pixels[i + 1] > pixels[i] + 10) bluePixels++;
|
||||
// Check that the pale material stays blue when composited on white.
|
||||
// A 20-level channel difference allows the selected white reflections.
|
||||
if ((pixels[i + 2] - pixels[i]) * pixels[i + 3] / 255 > 20) compositedBluePixels++;
|
||||
if (Math.hypot(x, y) > 79) outerPixels++;
|
||||
}
|
||||
snapshots.push({ image: canvas.toDataURL(), visiblePixels, bluePixels, outerPixels, compositedBluePixels, minimumBodyAlpha });
|
||||
}
|
||||
return snapshots;
|
||||
} finally {
|
||||
machine.delete(); artboard.delete(); file.delete(); renderer.delete();
|
||||
}
|
||||
};
|
||||
});`;
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
ready: Promise<void>;
|
||||
renderResource: (version: string, steps: { state: string; frames: number; hovered?: boolean; pointer?: [number, number] }[]) => Promise<{
|
||||
image: string;
|
||||
visiblePixels: number;
|
||||
bluePixels: number;
|
||||
outerPixels: number;
|
||||
compositedBluePixels: number;
|
||||
minimumBodyAlpha: number;
|
||||
}[]>;
|
||||
}
|
||||
}
|
||||
|
||||
let script: string;
|
||||
let original: Buffer;
|
||||
let recolored: Buffer;
|
||||
let modified: Buffer;
|
||||
let motionOnly: Buffer;
|
||||
let wasm: Buffer;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const source = JSON.parse(await readFile("design/agent/obsidian-runtime.json", "utf8"));
|
||||
original = encodeSource(source);
|
||||
recolored = buildMaterialPersona(source);
|
||||
modified = await readFile("public/agent/obsidian-water-v1.riv");
|
||||
expect(buildOfflinePersona(source)).toEqual(modified);
|
||||
const offline = JSON.parse(await readFile("design/agent/obsidian-offline.json", "utf8"));
|
||||
for (const layer of offline.layers) {
|
||||
for (const light of layer.opacity) light.rest = light.crest = 1;
|
||||
}
|
||||
motionOnly = buildOfflinePersona(source, offline);
|
||||
const require = createRequire(import.meta.url);
|
||||
const riveRequire = createRequire(require.resolve("@rive-app/react-webgl2"));
|
||||
wasm = await readFile(riveRequire.resolve("@rive-app/webgl2/rive.wasm"));
|
||||
const result = await build({
|
||||
stdin: { contents: harness, resolveDir: process.cwd() },
|
||||
bundle: true, write: false, format: "iife"
|
||||
});
|
||||
script = result.outputFiles[0].text;
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route("**/rive-probe/**", async (route) => {
|
||||
const path = new URL(route.request().url()).pathname;
|
||||
if (path.endsWith(".wasm")) return route.fulfill({ body: wasm, contentType: "application/wasm" });
|
||||
if (path.endsWith(".riv")) return route.fulfill({
|
||||
body: path.includes("original") ? original : path.includes("recolored") ? recolored : path.includes("motion-only") ? motionOnly : modified
|
||||
});
|
||||
if (path.endsWith(".js")) return route.fulfill({ body: script, contentType: "application/javascript" });
|
||||
return route.fulfill({
|
||||
contentType: "text/html",
|
||||
body: '<canvas width="192" height="192"></canvas><script src="/rive-probe/harness.js"></script>'
|
||||
});
|
||||
});
|
||||
await page.goto("/rive-probe/");
|
||||
await page.evaluate(() => window.ready);
|
||||
});
|
||||
|
||||
test("uses the blue palette while preserving the recolored original's online states pixel for pixel", async ({ page }) => {
|
||||
for (const state of ["idle", "thinking", "listening", "speaking", "hover"]) {
|
||||
const result = await page.evaluate(async (state) => {
|
||||
const steps = [{ state: "idle", frames: 90 }, { state, frames: 180 }, { state, frames: 180 }];
|
||||
return {
|
||||
original: await window.renderResource("original", steps),
|
||||
recolored: await window.renderResource("recolored", steps),
|
||||
modified: await window.renderResource("modified", steps)
|
||||
};
|
||||
}, state);
|
||||
expect(result.original[1].visiblePixels).toBeGreaterThan(12000);
|
||||
expect(result.original[1].image).not.toBe(result.original[2].image);
|
||||
expect(result.modified[1].image).not.toBe(result.original[1].image);
|
||||
expect(result.modified[1].bluePixels).toBeGreaterThan(10000);
|
||||
expect(result.modified[1].compositedBluePixels).toBeGreaterThan(6000);
|
||||
expect(result.modified[1].minimumBodyAlpha).toBeGreaterThanOrEqual(230);
|
||||
expect(result.modified, state).toEqual(result.recolored);
|
||||
}
|
||||
});
|
||||
|
||||
test("original-size hover reflections follow the pointer instead of whitening the fixed center", async ({ page }) => {
|
||||
const result = await page.evaluate(async () => {
|
||||
const pixelsOf = async (source: string) => {
|
||||
const image = new Image();
|
||||
image.src = source;
|
||||
await image.decode();
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = canvas.height = 192;
|
||||
const context = canvas.getContext("2d")!;
|
||||
context.drawImage(image, 0, 0);
|
||||
return context.getImageData(0, 0, 192, 192).data;
|
||||
};
|
||||
const steps = [
|
||||
{ state: "idle", frames: 90 },
|
||||
{ state: "hover", frames: 180, pointer: [24, 64] as [number, number] },
|
||||
{ state: "hover", frames: 1, pointer: [104, 64] as [number, number] },
|
||||
{ state: "idle", frames: 180 }
|
||||
];
|
||||
const original = await window.renderResource("recolored", steps);
|
||||
const edited = await window.renderResource("modified", steps);
|
||||
const idle = await window.renderResource("modified", steps.map(({ frames }) => ({ state: "idle", frames })));
|
||||
const centers = [];
|
||||
for (const index of [1, 2]) {
|
||||
const base = await pixelsOf(idle[index].image);
|
||||
const active = await pixelsOf(edited[index].image);
|
||||
let weight = 0;
|
||||
let weightedX = 0;
|
||||
for (let i = 0; i < base.length; i += 4) {
|
||||
const brightness = Math.max(0, active[i] - base[i]);
|
||||
if (brightness <= 8) continue;
|
||||
weight += brightness;
|
||||
weightedX += ((i / 4) % 192 + 0.5) * brightness;
|
||||
}
|
||||
centers.push({ x: weightedX / weight, weight });
|
||||
}
|
||||
return { centers, original, edited, recovered: edited[3].image === idle[3].image };
|
||||
});
|
||||
expect(result.edited).toEqual(result.original);
|
||||
expect(result.centers[0].weight).toBeGreaterThan(1000);
|
||||
expect(result.centers[1].weight).toBeGreaterThan(1000);
|
||||
expect(result.centers[0].x).toBeLessThan(84);
|
||||
expect(result.centers[1].x).toBeGreaterThan(108);
|
||||
expect(result.centers[1].x - result.centers[0].x).toBeGreaterThan(30);
|
||||
expect(result.recovered).toBe(true);
|
||||
});
|
||||
|
||||
test("the original disappears offline while the edited resource keeps moving and recovers", async ({ page }) => {
|
||||
const result = await page.evaluate(async () => {
|
||||
const steps = [
|
||||
{ state: "idle", frames: 90 }, { state: "asleep", frames: 180 },
|
||||
{ state: "asleep", frames: 180 }, { state: "idle", frames: 120 },
|
||||
{ state: "asleep", frames: 180 }, { state: "idle", frames: 120 }
|
||||
];
|
||||
return {
|
||||
original: await window.renderResource("original", steps),
|
||||
modified: await window.renderResource("modified", steps),
|
||||
initiallyOffline: await window.renderResource("modified", [{ state: "asleep", frames: 180 }])
|
||||
};
|
||||
});
|
||||
expect(result.original[1].visiblePixels).toBe(0);
|
||||
expect(result.original[2].visiblePixels).toBe(0);
|
||||
for (const frame of [...result.modified, ...result.initiallyOffline]) {
|
||||
expect(frame.visiblePixels).toBeGreaterThan(12000);
|
||||
expect(frame.minimumBodyAlpha).toBeGreaterThanOrEqual(230);
|
||||
}
|
||||
expect(result.modified[1].image).not.toBe(result.modified[2].image);
|
||||
});
|
||||
|
||||
test("offline light settles without hiding the sphere and fully restores after reconnecting", async ({ page }) => {
|
||||
const result = await page.evaluate(async () => {
|
||||
const steps = [
|
||||
{ state: "idle", frames: 90 },
|
||||
{ state: "asleep", frames: 180 },
|
||||
{ state: "asleep", frames: 180 },
|
||||
{ state: "idle", frames: 120 },
|
||||
{ state: "asleep", frames: 8 },
|
||||
{ state: "idle", frames: 8 },
|
||||
{ state: "asleep", frames: 8 },
|
||||
{ state: "idle", frames: 180 },
|
||||
{ state: "thinking", frames: 180 },
|
||||
{ state: "listening", frames: 180 },
|
||||
{ state: "speaking", frames: 180 },
|
||||
{ state: "hover", frames: 180 }
|
||||
];
|
||||
return {
|
||||
control: await window.renderResource("motion-only", steps),
|
||||
edited: await window.renderResource("modified", steps)
|
||||
};
|
||||
});
|
||||
for (const snapshot of result.edited) expect(snapshot.visiblePixels).toBeGreaterThan(12000);
|
||||
for (const index of [1, 2]) {
|
||||
expect(result.edited[index].image).not.toBe(result.control[index].image);
|
||||
expect(result.edited[index].outerPixels).toBe(0);
|
||||
}
|
||||
for (const index of [0, 3, 7, 8, 9, 10, 11]) {
|
||||
expect(result.edited[index], `recovered frame ${index}`).toEqual(result.control[index]);
|
||||
}
|
||||
});
|
||||
|
||||
test("offline has no outer halo, including hover and cold start, and reconnecting restores it", async ({ page }) => {
|
||||
const snapshots = await page.evaluate(() => window.renderResource("modified", [
|
||||
{ state: "asleep", frames: 180 },
|
||||
{ state: "asleep", frames: 180, hovered: true },
|
||||
{ state: "asleep", frames: 180 },
|
||||
{ state: "idle", frames: 120 }
|
||||
]));
|
||||
for (const frame of snapshots.slice(0, 3)) {
|
||||
expect(frame.visiblePixels).toBeGreaterThan(12000);
|
||||
expect(frame.bluePixels).toBeGreaterThan(10000);
|
||||
expect(frame.compositedBluePixels).toBeGreaterThan(6000);
|
||||
expect(frame.outerPixels).toBe(0);
|
||||
}
|
||||
expect(snapshots[0].image).not.toBe(snapshots[2].image);
|
||||
expect(snapshots[3].outerPixels).toBeGreaterThan(100);
|
||||
});
|
||||
@@ -3,21 +3,12 @@ import { mockAgentApi } from "./support/mock-agent-api";
|
||||
import { TEST_PROJECT_ID } from "./support/mock-scada-api";
|
||||
import { mockWorkbenchContext } from "./support/mock-workbench-context";
|
||||
|
||||
for (const { scope, width } of [
|
||||
{ scope: "project", width: 1440 },
|
||||
{ scope: "personal", width: 1440 },
|
||||
{ scope: "personal", width: 375 }
|
||||
] as const) {
|
||||
test(`authenticated ${scope} chat sends the matching context at ${width}px`, async ({ page }) => {
|
||||
for (const width of [1440, 375] as const) {
|
||||
test(`authenticated chat always sends the current project at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await mockWorkbenchContext(page);
|
||||
await mockAgentApi(page);
|
||||
if (scope === "personal") {
|
||||
await page.route("**/api/v1/projects?*", (route) => route.fulfill({
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
json: { items: [], total: 0, limit: 1000, offset: 0 }
|
||||
}));
|
||||
}
|
||||
// Removing the scope picker must keep the authorized project on every chat request.
|
||||
await page.route("**/runtime-config.js", (route) => route.fulfill({
|
||||
contentType: "application/javascript",
|
||||
body: `globalThis.__TJWATER_CONFIG__ = ${JSON.stringify({
|
||||
@@ -45,7 +36,7 @@ test(`authenticated ${scope} chat sends the matching context at ${width}px`, asy
|
||||
}`
|
||||
}));
|
||||
const streamHeaders: Array<Record<string, string>> = [];
|
||||
const expectedProject = scope === "project" ? TEST_PROJECT_ID : undefined;
|
||||
const expectedProject = TEST_PROJECT_ID;
|
||||
await page.route("**/api/v1/responses", async (route) => {
|
||||
if (route.request().method() !== "POST") {
|
||||
await route.fallback();
|
||||
@@ -70,11 +61,8 @@ test(`authenticated ${scope} chat sends the matching context at ${width}px`, asy
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
|
||||
const panel = page.locator('aside[aria-label="Agent 命令面板"]').filter({ visible: true });
|
||||
if (scope === "personal") {
|
||||
await panel.getByRole("button", { name: "选择对话归属" }).click();
|
||||
await page.getByRole("menuitem", { name: "个人会话", exact: true }).click();
|
||||
await expect(panel.getByRole("button", { name: "选择对话归属" })).toHaveText("个人会话");
|
||||
}
|
||||
await expect(panel.getByRole("button", { name: /选择对话归属|选择数据范围/ })).toHaveCount(0);
|
||||
await expect(panel.getByText("项目数据", { exact: true })).toHaveCount(0);
|
||||
await expect(panel.getByRole("button", { name: "选择 Agent 模型" })).toContainText("测试模型");
|
||||
await panel.getByRole("textbox").fill("检查会话项目上下文");
|
||||
await panel.getByRole("button", { name: "发送 Agent 指令" }).click();
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { answerUiDemoBlock } from "./support/answer-ui-demo";
|
||||
import { mockAgentApi } from "./support/mock-agent-api";
|
||||
import { captureReadingGeometry } from "./support/reading-geometry";
|
||||
|
||||
for (const width of [1600, 375, 320]) {
|
||||
test(`formal rich reports preserve drafts, map and versioned references at ${width}px`, async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width, height: 960 });
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await mockAgentApi(page);
|
||||
// Load Vite's React preamble without mounting a second application. Its
|
||||
// global resize listeners would otherwise survive page.setContent().
|
||||
await page.route("**/src/main.tsx", (route) => route.fulfill({ contentType: "application/javascript", body: "" }));
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.unroute("**/src/main.tsx");
|
||||
await page.setContent('<!doctype html><html lang="zh-CN"><head><link rel="stylesheet" href="/src/styles.css"></head><body><main id="reader"></main></body></html>');
|
||||
await page.evaluate(async ({ block, width }) => {
|
||||
const entry = await (await fetch("/src/main.tsx")).text();
|
||||
const reactUrl = entry.match(/from "([^"\n]*\/react\.js[^"\n]*)"/)?.[1];
|
||||
const clientUrl = entry.match(/from "([^"\n]*\/react-dom_client\.js[^"\n]*)"/)?.[1];
|
||||
if (!reactUrl || !clientUrl) throw new Error("Vite React paths unavailable");
|
||||
const reactModule = await import(reactUrl);
|
||||
const React = reactModule.default ?? reactModule;
|
||||
const clientModule = await import(clientUrl);
|
||||
const { createRoot } = clientModule.default ?? clientModule;
|
||||
const { InvestigationTaskReport } = await import("/src/features/workbench/components/investigation-task-report.tsx");
|
||||
const { ReadingWorkspaceProvider } = await import("/src/shared/ui/reading-workspace.tsx");
|
||||
const { useReadingWorkspace } = await import("/src/shared/ui/reading-workspace-context.ts");
|
||||
const { WorkbenchMainFrame } = await import("/src/features/workbench/workspace/workbench-main-frame.tsx");
|
||||
const now = "2026-10-20T08:00:00Z";
|
||||
const report = { revision: 3, execution_generation: 2, published_at: now, report: {
|
||||
title: "供水压力演练核验", current_conclusion: "当前演练数据提示压力变化,仍待现场核验。",
|
||||
conclusion_boundary: "仅覆盖已加载的演练记录。", stage: "final", outcome: "concluded",
|
||||
data_gaps: [], recommendations: ["核对现场位置及对应时段。"], pending_items: [], evidence_refs: ["DEMO-001"],
|
||||
ui_blocks: [
|
||||
{ block, evidence_refs: ["DEMO-001"] },
|
||||
{ block: { version: "answer-ui@future", block_id: "future", fallback_text: "扩展分析摘要:等待下一次现场核验。", spec: {} }, evidence_refs: ["DEMO-001"] },
|
||||
],
|
||||
} };
|
||||
const historical = { ...report, revision: 2, report: { ...report.report, current_conclusion: "历史结论:初次演练核验。" } };
|
||||
const task = { task_id: "task-ui-demo", title: "供水压力演练核验", summary: "当前报告已发布",
|
||||
execution_generation: 2, lifecycle_status: "completed", status: "completed", updated_at: now,
|
||||
latest_report: report, latest_progress: null, latest_plan: null, actions: [],
|
||||
evidence: [{ evidence_id: "DEMO-001", kind: "synthetic", summary: "保存的演练监测记录,等待现场核验。",
|
||||
source_label: "演练监测记录", observed_at: now, recorded_at: now }],
|
||||
};
|
||||
const noAction = () => undefined;
|
||||
function Harness() {
|
||||
const reading = useReadingWorkspace();
|
||||
return React.createElement("div", { style: { "--workbench-agent-current-width": "400px" } },
|
||||
React.createElement("div", { style: { position: "absolute", inset: 0, left: width >= 1024 ? 400 : 0 } },
|
||||
React.createElement(WorkbenchMainFrame, { mapContent: React.createElement("div", { "data-map-instance": true }, "演练地图工作区"),
|
||||
timelineContent: null, timelineMode: "compact", timelineBottom: 0, activeArtifact: null, pendingArtifact: null,
|
||||
surfaceMode: "map_only", mapSplitAvailable: width >= 1360, readingSplitAvailable: width >= 1360,
|
||||
onOpenPendingArtifact: noAction, onDestroyPendingArtifact: noAction, onSetArtifactView: noAction,
|
||||
onCollapseArtifact: noAction, onDestroyArtifact: noAction })),
|
||||
React.createElement("aside", { "aria-label": "报告列表", style: {
|
||||
position: "absolute", inset: "0 auto 0 0", width: width >= 1024 ? 400 : "100%", overflow: "auto", padding: 16,
|
||||
display: width < 1024 && reading.active ? "none" : "block",
|
||||
} }, React.createElement(InvestigationTaskReport, { task,
|
||||
onContinue: (discussion) => { Object.assign(window, { quotedReport: discussion }); },
|
||||
renderAction: noAction, renderEvidence: () => React.createElement("p", null, "原始演练记录"),
|
||||
onLoadReports: async () => ({ items: [report, historical], next_offset: null }),
|
||||
})),
|
||||
);
|
||||
}
|
||||
createRoot(document.getElementById("reader")).render(React.createElement(ReadingWorkspaceProvider, null, React.createElement(Harness)));
|
||||
}, { block: answerUiDemoBlock, width });
|
||||
await page.getByLabel("现场位置(必填)").fill("展开前的现场资料");
|
||||
await expect(page.getByRole("navigation", { name: "报告章节" })).toBeHidden();
|
||||
const map = page.locator("[data-map-instance]");
|
||||
await map.evaluate((element) => Object.assign(window, { originalMap: element }));
|
||||
await page.getByRole("button", { name: "展开查看", exact: true }).click();
|
||||
const workspace = page.getByRole("region", { name: "展开报告工作区", exact: true });
|
||||
await expect(workspace).toContainText("当前演练数据提示压力变化,仍待现场核验。");
|
||||
await expect(workspace.getByLabel("现场位置(必填)")).toHaveValue("展开前的现场资料");
|
||||
await expect(page.getByLabel("现场位置(必填)")).toHaveCount(1);
|
||||
const chapters = workspace.getByRole("navigation", { name: "报告章节" });
|
||||
await expect(chapters).toBeVisible();
|
||||
await expect(chapters.getByRole("button", { name: "待办", exact: true })).toHaveCount(0);
|
||||
await chapters.getByRole("button", { name: "依据", exact: true }).focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(workspace.getByRole("region", { name: "主要依据", exact: true })).toBeFocused();
|
||||
await testInfo.attach("chapter-jump-geometry", {
|
||||
body: JSON.stringify(await workspace.evaluate((root) => {
|
||||
const scroll = root.querySelector("[data-reading-scroll]")!;
|
||||
return {
|
||||
scrollTop: scroll.scrollTop,
|
||||
maxScroll: scroll.scrollHeight - scroll.clientHeight,
|
||||
sections: [...root.querySelectorAll(".investigation-report > section")].map((section) => ({
|
||||
label: section.getAttribute("aria-label"),
|
||||
top: Math.round(section.getBoundingClientRect().top - scroll.getBoundingClientRect().top),
|
||||
})),
|
||||
};
|
||||
}), null, 2), contentType: "application/json",
|
||||
});
|
||||
await expect(chapters.getByRole("button", { name: "依据", exact: true })).toHaveAttribute("aria-current", "location");
|
||||
const navigationTop = (await chapters.boundingBox())!.y;
|
||||
const scrollTop = (await workspace.locator("[data-reading-scroll]").boundingBox())!.y;
|
||||
expect(Math.abs(navigationTop - scrollTop)).toBeLessThan(2);
|
||||
await chapters.getByRole("button", { name: "分析", exact: true }).click();
|
||||
await expect(workspace.getByRole("region", { name: "交互分析", exact: true })).toBeFocused();
|
||||
await expect(workspace.getByLabel("现场位置(必填)")).toHaveValue("展开前的现场资料");
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await page.screenshot({ path: `test-results/formal-report-analysis-${width}.png` });
|
||||
await expect(workspace).toContainText("扩展分析摘要:等待下一次现场核验。");
|
||||
await workspace.getByRole("button", { name: /查看演练证据/ }).click();
|
||||
await expect(workspace).toContainText("保存的演练监测记录,等待现场核验。");
|
||||
if (width >= 1360) {
|
||||
await workspace.getByRole("button", { name: "地图对照" }).click();
|
||||
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "map_split");
|
||||
await expect(map).toBeVisible();
|
||||
const separator = page.getByRole("separator", { name: "调整地图与报告宽度" });
|
||||
await expect(separator).toHaveAttribute("data-slot", "resizable-handle");
|
||||
const mapPanel = page.locator("#workspace-map");
|
||||
const initialWidth = (await mapPanel.boundingBox())!.width;
|
||||
const handle = (await separator.boundingBox())!;
|
||||
await page.mouse.move(handle.x + handle.width / 2, handle.y + handle.height / 2);
|
||||
await expect(separator).toHaveAttribute("data-separator", "hover");
|
||||
await page.mouse.down();
|
||||
await expect(separator).toHaveAttribute("data-separator", "active");
|
||||
await page.mouse.move(handle.x + 100, handle.y + handle.height / 2);
|
||||
await expect.poll(async () => (await mapPanel.boundingBox())!.width).toBeGreaterThan(initialWidth + 90);
|
||||
await page.mouse.up();
|
||||
await expect.poll(async () => (await mapPanel.boundingBox())!.width).toBeGreaterThan(initialWidth + 90);
|
||||
await separator.focus();
|
||||
await page.keyboard.press("ArrowLeft");
|
||||
await expect.poll(async () => (await mapPanel.boundingBox())!.width).toBeLessThan(initialWidth + 90);
|
||||
const resizedWidth = (await mapPanel.boundingBox())!.width;
|
||||
await expect(workspace.getByLabel("现场位置(必填)")).toHaveValue("展开前的现场资料");
|
||||
await page.screenshot({ path: "test-results/resizable-report-map.png" });
|
||||
await workspace.getByRole("button", { name: "专注阅读" }).click();
|
||||
await expect(separator).toBeHidden();
|
||||
await workspace.getByRole("button", { name: "地图对照" }).click();
|
||||
await expect.poll(async () => (await mapPanel.boundingBox())!.width).toBeCloseTo(resizedWidth, 0);
|
||||
await workspace.getByRole("button", { name: "专注阅读" }).click();
|
||||
} else {
|
||||
await workspace.getByRole("button", { name: "查看地图" }).click();
|
||||
await expect(map).toBeVisible();
|
||||
await page.getByRole("button", { name: "返回报告", exact: true }).click();
|
||||
}
|
||||
expect(await map.evaluate((element) => element === (window as unknown as { originalMap: Element }).originalMap)).toBe(true);
|
||||
await workspace.locator("[data-reading-scroll]").evaluate((element) => { element.scrollTop = 0; });
|
||||
await expect(chapters.getByRole("button", { name: "结论", exact: true })).toHaveAttribute("aria-current", "location");
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
const geometry = await captureReadingGeometry(workspace, testInfo);
|
||||
expect(geometry.metricsHeight).toBeLessThan(145);
|
||||
expect(geometry.horizontalOverflow).toBe(false);
|
||||
await page.screenshot({ path: `test-results/formal-report-workspace-${width}.png` });
|
||||
await workspace.getByRole("button", { name: "展开报告版本" }).click();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("dialog", { name: "报告版本" })).toHaveCount(0);
|
||||
await expect(workspace).toBeVisible();
|
||||
await workspace.getByRole("button", { name: "展开报告版本" }).click();
|
||||
await page.getByRole("dialog", { name: "报告版本" }).getByRole("button", { name: /报告第 2 版/ }).click();
|
||||
await expect(workspace).toContainText("历史结论:初次演练核验。");
|
||||
await workspace.getByRole("tab", { name: "数据", exact: true }).click();
|
||||
await workspace.getByRole("checkbox", { name: "选择第 2 行" }).check();
|
||||
await workspace.locator("[data-interactive-table]").getByRole("button", { name: "针对这部分提问" }).click();
|
||||
const quote = await page.evaluate(() => (window as unknown as { quotedReport: { context: { report_revision: number }; reference: { title: string; text: string } } }).quotedReport);
|
||||
expect(quote.context.report_revision).toBe(2);
|
||||
expect(quote.reference.title).toContain("报告第 2 版");
|
||||
expect(quote.reference.text).toContain("0.28");
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createServer, type ServerResponse } from "node:http";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test, type Route } from "@playwright/test";
|
||||
import { mockAgentApi } from "./support/mock-agent-api";
|
||||
import { mockWorkbenchContext } from "./support/mock-workbench-context";
|
||||
import { responseFixtureWriter } from "./support/responses-fixture";
|
||||
@@ -11,6 +11,8 @@ for (const width of [1440, 375, 320]) {
|
||||
const admitted: Array<{ id: string; text: string; delivery: string }> = [];
|
||||
let response: ServerResponse | undefined;
|
||||
let encode = responseFixtureWriter();
|
||||
let heldWithdrawal: Route | undefined;
|
||||
let holdWithdrawal = false;
|
||||
const send = (chunk: Record<string, unknown>) => response!.write(encode(chunk));
|
||||
const server = createServer((_request, output) => {
|
||||
response = output;
|
||||
@@ -49,6 +51,15 @@ for (const width of [1440, 375, 320]) {
|
||||
return;
|
||||
}
|
||||
if (path.endsWith("/inputs")) { await route.fulfill({ headers, json: { items: [...pending.values()] } }); return; }
|
||||
if (path.includes("/inputs/") && request.method() === "DELETE") {
|
||||
if (holdWithdrawal) { heldWithdrawal = route; return; }
|
||||
const id = path.split("/").at(-1)!;
|
||||
const item = pending.get(id);
|
||||
if (!item) { await route.fulfill({ status: 409, headers, json: { message: "消息已开始处理,无法退回编辑。" } }); return; }
|
||||
pending.delete(id);
|
||||
await route.fulfill({ headers, json: { ...item, status: "cancelled" } });
|
||||
return;
|
||||
}
|
||||
if (path.includes("/inputs/") && request.method() === "PATCH") {
|
||||
const item = pending.get(path.split("/").at(-1)!);
|
||||
if (!item) { await route.fulfill({ status: 409, headers, json: { message: "Consumed" } }); return; }
|
||||
@@ -69,7 +80,7 @@ for (const width of [1440, 375, 320]) {
|
||||
await composer.fill("核对压力变化");
|
||||
await page.getByRole("button", { name: "发送 Agent 指令" }).click();
|
||||
const deliveryMode = page.getByRole("button", { name: "运行中消息发送方式" });
|
||||
await expect(deliveryMode).toHaveText("引导当前任务");
|
||||
await expect(deliveryMode).toHaveText("引导");
|
||||
await deliveryMode.focus();
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expect(page.getByRole("menuitemradio", { name: "引导当前任务" })).toHaveAttribute("aria-checked", "true");
|
||||
@@ -80,31 +91,89 @@ for (const width of [1440, 375, 320]) {
|
||||
await page.getByRole("menu").screenshot({ path: testInfo.outputPath("delivery-menu.png"), animations: "disabled" });
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(deliveryMode).toBeFocused();
|
||||
await expect(deliveryMode).toHaveText("引导当前任务");
|
||||
await expect(deliveryMode).toHaveText("引导");
|
||||
await expect(page.getByRole("button", { name: "停止 Agent 指令" })).toBeVisible();
|
||||
const actionSlot = await page.getByRole("button", { name: "停止 Agent 指令" }).boundingBox();
|
||||
await composer.fill("补充当前任务");
|
||||
await expect(page.getByRole("button", { name: "停止 Agent 指令" })).toHaveCount(0);
|
||||
const sendSlot = await page.getByRole("button", { name: "引导当前 Agent 任务" }).boundingBox();
|
||||
expect(sendSlot).toEqual(actionSlot);
|
||||
await page.getByRole("button", { name: "引导当前 Agent 任务" }).click();
|
||||
const pendingMode = page.getByRole("button", { name: "消息发送方式:补充当前任务" });
|
||||
await expect(pendingMode).toHaveText("引导当前任务");
|
||||
const queue = page.getByRole("list", { name: "待处理消息" });
|
||||
const pendingRow = queue.getByRole("listitem").filter({ hasText: "补充当前任务" });
|
||||
const pendingMode = pendingRow.getByRole("button", { name: "消息操作:补充当前任务" });
|
||||
await expect(pendingRow).toContainText("引导");
|
||||
await expect(pendingRow.getByRole("button")).toHaveCount(1);
|
||||
await pendingMode.click();
|
||||
await page.getByRole("menuitemradio", { name: "排队处理" }).click();
|
||||
await expect(pendingMode).toHaveText("排队处理");
|
||||
await expect(pendingRow).toContainText("排队");
|
||||
await pendingMode.click();
|
||||
await expect(page.getByRole("menuitemradio", { name: "排队处理" })).toHaveAttribute("aria-checked", "true");
|
||||
const steerOption = page.getByRole("menuitemradio", { name: "引导当前任务" });
|
||||
await steerOption.hover();
|
||||
await expectThemeColor(steerOption, "background-color", "--action-selection-soft-hover");
|
||||
await page.screenshot({ path: testInfo.outputPath("pending-delivery-menu.png"), animations: "disabled" });
|
||||
await page.keyboard.press("Home");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(pendingMode).toHaveText("引导当前任务");
|
||||
await steerOption.click();
|
||||
await expect(pendingRow).toContainText("引导");
|
||||
await expect(pendingMode).toBeFocused();
|
||||
await deliveryMode.click();
|
||||
await page.getByRole("menuitemradio", { name: "排队处理" }).click();
|
||||
await composer.fill("下一轮核对流量");
|
||||
await page.getByRole("button", { name: "将消息加入 Agent 队列" }).click();
|
||||
await expect(page.getByRole("button", { name: "消息发送方式:下一轮核对流量" })).toHaveText("排队处理");
|
||||
await expect(queue.getByRole("listitem").filter({ hasText: "下一轮核对流量" })).toContainText("排队");
|
||||
expect(admitted.map((item) => item.delivery)).toEqual(["steer", "queue"]);
|
||||
const longInput = "下一轮请检查所有压力测点,并说明每个异常的证据及时间范围。\n请逐一列出测点名称、压力变化、读数时刻和关联管段,保留缺失数据的说明,避免把未核实的推测写成结论。\n保留这段完整的补充内容,末尾核对流量。";
|
||||
await composer.fill(longInput);
|
||||
await page.getByRole("button", { name: "将消息加入 Agent 队列" }).click();
|
||||
const longRow = queue.getByRole("listitem").filter({ hasText: longInput });
|
||||
await expect(longRow).toBeVisible();
|
||||
await expect(longRow.locator("p")).toHaveText(longInput);
|
||||
const expandMessage = longRow.getByRole("button", { name: "展开全文" });
|
||||
await expect(expandMessage).toBeVisible();
|
||||
await expect(expandMessage).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(page.locator("form").getByRole("list", { name: "待处理消息" })).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath("pending-messages-compact.png"), animations: "disabled" });
|
||||
await expandMessage.click();
|
||||
await expect(longRow.getByRole("button", { name: "收起全文" })).toHaveAttribute("aria-expanded", "true");
|
||||
expect(await longRow.locator("p").evaluate((node) => node.scrollWidth <= node.clientWidth)).toBe(true);
|
||||
expect(await longRow.locator("p").evaluate((node) => node.scrollHeight <= node.clientHeight)).toBe(true);
|
||||
await page.screenshot({ path: testInfo.outputPath("pending-message-content.png"), animations: "disabled" });
|
||||
await longRow.getByRole("button", { name: /^消息操作:/ }).focus();
|
||||
await page.keyboard.press("ArrowDown");
|
||||
holdWithdrawal = true;
|
||||
await page.getByRole("menuitem", { name: "编辑消息" }).click();
|
||||
await expect.poll(() => Boolean(heldWithdrawal)).toBe(true);
|
||||
await composer.fill("正在输入的新草稿");
|
||||
const withdrawnId = new URL(heldWithdrawal!.request().url()).pathname.split("/").at(-1)!;
|
||||
const withdrawn = pending.get(withdrawnId)!;
|
||||
pending.delete(withdrawnId);
|
||||
await heldWithdrawal!.fulfill({ headers: { "Access-Control-Allow-Origin": "*" }, json: { ...withdrawn, status: "cancelled" } });
|
||||
heldWithdrawal = undefined; holdWithdrawal = false;
|
||||
await expect(composer).toHaveValue(`正在输入的新草稿\n\n${longInput}`);
|
||||
await expect(composer).toBeFocused();
|
||||
await expect(queue.getByRole("listitem")).toHaveCount(2);
|
||||
expect(admitted).toHaveLength(3);
|
||||
await composer.fill("编辑后重新发送的消息");
|
||||
await page.getByRole("button", { name: "将消息加入 Agent 队列" }).click();
|
||||
const editedRow = queue.getByRole("listitem").filter({ hasText: "编辑后重新发送的消息" });
|
||||
await editedRow.getByRole("button", { name: /^消息操作:/ }).click();
|
||||
await page.getByRole("menuitem", { name: "关闭排队" }).click();
|
||||
await expect(editedRow).toHaveCount(0);
|
||||
await expect(composer).toHaveValue("");
|
||||
// Consumption can win the race while the edit menu is open.
|
||||
await composer.fill("保留当前草稿");
|
||||
const consumedRow = queue.getByRole("listitem").filter({ hasText: "补充当前任务" });
|
||||
await consumedRow.getByRole("button", { name: /^消息操作:/ }).click();
|
||||
holdWithdrawal = true;
|
||||
await page.getByRole("menuitem", { name: "编辑消息" }).click();
|
||||
await expect.poll(() => Boolean(heldWithdrawal)).toBe(true);
|
||||
const consumed = pending.get(admitted[0].id)!;
|
||||
pending.delete(consumed.id);
|
||||
send({ type: "data-user_input", data: { ...consumed, session_id: "input-session", status: "delivered" } });
|
||||
await heldWithdrawal!.fulfill({ status: 409, headers: { "Access-Control-Allow-Origin": "*" }, json: { message: "消息已开始处理,无法退回编辑。" } });
|
||||
heldWithdrawal = undefined; holdWithdrawal = false;
|
||||
await expect(page.getByText("无法退回编辑", { exact: true })).toBeVisible();
|
||||
await expect(composer).toHaveValue("保留当前草稿");
|
||||
expect(primaryRequests).toBe(1);
|
||||
expect(abortRequests).toBe(0);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
||||
@@ -124,7 +193,14 @@ for (const width of [1440, 375, 320]) {
|
||||
await expect(page.getByRole("button", { name: "停止 Agent 指令" })).toBeVisible();
|
||||
await composer.fill("等待处理的后续问题");
|
||||
await page.getByRole("button", { name: "将消息加入 Agent 队列" }).click();
|
||||
await expect(page.getByRole("button", { name: "消息发送方式:等待处理的后续问题" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "消息操作:等待处理的后续问题" })).toBeVisible();
|
||||
await page.setViewportSize({ width, height: 640 });
|
||||
await expect.poll(async () => {
|
||||
const stopBox = await page.getByRole("button", { name: "停止 Agent 指令" }).boundingBox();
|
||||
return stopBox!.y + stopBox!.height;
|
||||
}).toBeLessThanOrEqual(640);
|
||||
await page.screenshot({ path: testInfo.outputPath("pending-message-short-window.png"), animations: "disabled" });
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
||||
await page.getByRole("button", { name: "停止 Agent 指令" }).click();
|
||||
await expect(page.getByRole("list", { name: "待处理消息" })).toHaveCount(0);
|
||||
expect(abortRequests).toBe(1);
|
||||
|
||||
@@ -1,7 +1,29 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test, type Locator } from "@playwright/test";
|
||||
import { mockAgentApi } from "./support/mock-agent-api";
|
||||
import { mockWorkbenchContext } from "./support/mock-workbench-context";
|
||||
|
||||
async function expectCenteredStart(panel: Locator) {
|
||||
await expect(panel).toHaveAttribute("data-agent-centered", "true");
|
||||
await expect.poll(async () => {
|
||||
const body = (await panel.locator(".agent-panel-body").boundingBox())!;
|
||||
const group = (await panel.locator(".agent-panel-band").boundingBox())!;
|
||||
return Math.abs(group.y + group.height / 2 - body.y - body.height / 2);
|
||||
}).toBeLessThan(2);
|
||||
const body = (await panel.locator(".agent-panel-body").boundingBox())!;
|
||||
const form = (await panel.locator("form").boundingBox())!;
|
||||
expect(Math.abs(form.x + form.width / 2 - body.x - body.width / 2)).toBeLessThan(1);
|
||||
expect(form.width).toBeLessThanOrEqual(552);
|
||||
await expect(panel.locator("form")).toHaveCount(1);
|
||||
}
|
||||
|
||||
async function preparePreview(page: import("@playwright/test").Page) {
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
// Close this snapshot together; positional locators shift as earlier notices exit.
|
||||
await page.getByRole("button", { name: "关闭通知", exact: true }).evaluateAll((buttons) => {
|
||||
for (const button of buttons) (button as HTMLButtonElement).click();
|
||||
});
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await mockWorkbenchContext(page);
|
||||
@@ -10,7 +32,7 @@ test.beforeEach(async ({ page }) => {
|
||||
}] });
|
||||
});
|
||||
|
||||
test("empty conversation centers one composer and directly sends an Agent-generated recent activity question", async ({ page }) => {
|
||||
test("empty conversation centers the first question and sends an Agent-generated recent activity question", async ({ page }) => {
|
||||
let requests = 0;
|
||||
await page.route("**/api/v1/suggestions", async (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback();
|
||||
@@ -27,15 +49,17 @@ test("empty conversation centers one composer and directly sends an Agent-genera
|
||||
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
|
||||
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
|
||||
await expect(panel).toHaveAttribute("data-agent-starting", "true");
|
||||
await expect(panel.getByRole("heading", { name: "我能帮你处理什么?" })).toBeVisible();
|
||||
await expect(panel.getByRole("heading", { name: "有什么可以帮你?" })).toBeVisible();
|
||||
const question = panel.getByRole("button", { name: /重新核对城南供水压力复核涉及的监测证据/ });
|
||||
await expect(question).toBeVisible();
|
||||
await expect(question).toContainText("近期会话");
|
||||
const composer = (await panel.locator(".agent-panel-start-composer").boundingBox())!;
|
||||
const panelBox = (await panel.boundingBox())!;
|
||||
expect(Math.abs(composer.y + composer.height / 2 - (panelBox.y + 56 + (panelBox.height - 56) / 2))).toBeLessThan(1);
|
||||
const composer = (await panel.locator("form").boundingBox())!;
|
||||
await expectCenteredStart(panel);
|
||||
const suggestions = (await panel.getByRole("group", { name: "推荐问题" }).boundingBox())!;
|
||||
expect(suggestions.y).toBeGreaterThan(composer.y + composer.height);
|
||||
expect(composer.height).toBeLessThan(150);
|
||||
await expect(panel.locator("form")).toHaveCount(1);
|
||||
await panel.screenshot({ path: "test-results/agent-history-ui/agent-start-generated.png" });
|
||||
await panel.screenshot({ path: "/tmp/nexttjwater-agent-start-preview/centered-desktop.png" });
|
||||
const handle = page.getByRole("separator", { name: "调整 Agent 面板宽度" });
|
||||
for (const width of [400, 640]) {
|
||||
const box = (await handle.boundingBox())!;
|
||||
@@ -43,14 +67,15 @@ test("empty conversation centers one composer and directly sends an Agent-genera
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(width, box.y + box.height / 2);
|
||||
await page.mouse.up();
|
||||
await expect(panel).toHaveCSS("width", `${width}px`);
|
||||
await expect.poll(async () => Math.round((await panel.boundingBox())!.width)).toBe(width);
|
||||
await expectCenteredStart(panel);
|
||||
}
|
||||
expect(requests).toBe(1);
|
||||
await question.click();
|
||||
await expect(panel).toHaveAttribute("data-agent-starting", "false");
|
||||
await expect(panel.locator(".agent-panel-composer-motion")).toHaveCSS("transform", "none");
|
||||
await expect(panel.locator("textarea")).toHaveCSS("min-height", "96px");
|
||||
expect((await panel.locator(".agent-panel-band").last().boundingBox())!.height).toBeLessThanOrEqual(160);
|
||||
await expect(panel.locator("textarea")).toHaveCSS("min-height", "64px");
|
||||
expect((await panel.locator(".agent-panel-band").last().boundingBox())!.height).toBeLessThanOrEqual(180);
|
||||
await expect(panel.locator(".agent-panel-user-message")).toHaveText("重新核对城南供水压力复核涉及的监测证据");
|
||||
await expect(panel.locator("form")).toHaveCount(1);
|
||||
await expect.poll(async () => {
|
||||
@@ -60,6 +85,16 @@ test("empty conversation centers one composer and directly sends an Agent-genera
|
||||
}).toBeLessThan(1);
|
||||
await panel.screenshot({ path: "test-results/agent-history-ui/agent-conversation-compact.png" });
|
||||
|
||||
|
||||
});
|
||||
|
||||
test("multiline follow-up stays reachable in a short desktop viewport", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
|
||||
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
|
||||
await panel.locator("textarea").fill("核对供水压力");
|
||||
await panel.getByRole("button", { name: "发送 Agent 指令" }).click();
|
||||
await expect(panel).toHaveAttribute("data-agent-starting", "false");
|
||||
const prompt = panel.locator("textarea");
|
||||
await prompt.focus();
|
||||
await page.keyboard.type("核对监测点");
|
||||
@@ -67,13 +102,14 @@ test("empty conversation centers one composer and directly sends an Agent-genera
|
||||
await page.keyboard.type("补充现场记录");
|
||||
await expect(prompt).toHaveValue("核对监测点\n补充现场记录");
|
||||
const initialHeight = (await prompt.boundingBox())!.height;
|
||||
expect(initialHeight).toBe(96);
|
||||
expect(initialHeight).toBeGreaterThanOrEqual(64);
|
||||
expect(initialHeight).toBeLessThanOrEqual(70);
|
||||
await prompt.fill("监测点、异常时间及现场核验记录。\n".repeat(30));
|
||||
for (const height of [900, 640]) {
|
||||
await page.setViewportSize({ width: 1280, height });
|
||||
const promptBox = (await prompt.boundingBox())!;
|
||||
expect(promptBox.height).toBeGreaterThan(initialHeight);
|
||||
expect(promptBox.height).toBeLessThanOrEqual(Math.min(height * 0.24, 192));
|
||||
expect(promptBox.height).toBeLessThanOrEqual(Math.min(height * 0.24, 160));
|
||||
expect(promptBox.x).toBeGreaterThanOrEqual(0);
|
||||
await expect.poll(() => prompt.evaluate((element) => {
|
||||
const input = element.getBoundingClientRect();
|
||||
@@ -88,11 +124,11 @@ test("empty conversation centers one composer and directly sends an Agent-genera
|
||||
expect((await panel.getByRole("log").boundingBox())!.height).toBeGreaterThan(100);
|
||||
}
|
||||
await prompt.fill("");
|
||||
await expect(prompt).toHaveCSS("height", "96px");
|
||||
await expect(prompt).toHaveCSS("height", "64px");
|
||||
await panel.screenshot({ path: "test-results/agent-history-ui/agent-conversation-short-viewport.png" });
|
||||
});
|
||||
|
||||
test("failed recommendation generation leaves the centered input ready for a normal conversation", async ({ page }) => {
|
||||
test("failed recommendation generation leaves the input ready for a normal conversation", async ({ page }) => {
|
||||
await page.route("**/api/v1/suggestions", (route) => route.request().method() === "OPTIONS" ? route.fallback() : route.fulfill({
|
||||
status: 503, headers: { "Access-Control-Allow-Origin": "*" }, json: { message: "unavailable" },
|
||||
}));
|
||||
@@ -107,47 +143,81 @@ test("failed recommendation generation leaves the centered input ready for a nor
|
||||
await expect(panel).toHaveAttribute("data-agent-starting", "false");
|
||||
});
|
||||
|
||||
test("first send smoothly moves the existing composer to the bottom", async ({ page }) => {
|
||||
test("sending moves the same centered composer to the bottom and new conversation restores the start", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||
await mockAgentApi(page, { sessions: [] });
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
|
||||
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
|
||||
await expect(panel.getByRole("group", { name: "推荐问题" }).getByRole("button")).toHaveCount(3);
|
||||
await panel.evaluate(async (element) => {
|
||||
await Promise.all(element.getAnimations({ subtree: true })
|
||||
.filter((animation) => animation.effect?.getTiming().iterations !== Infinity)
|
||||
.map((animation) => animation.finished.catch(() => undefined)));
|
||||
});
|
||||
const form = panel.locator("form");
|
||||
await preparePreview(page);
|
||||
await panel.screenshot({ path: "/tmp/nexttjwater-agent-start-preview/centered-desktop.png" });
|
||||
await form.locator("textarea").fill("核对近期供水压力变化");
|
||||
const beforeTop = (await form.boundingBox())!.y;
|
||||
await page.evaluate(() => {
|
||||
const panel = document.querySelector('aside[aria-label="Agent 命令面板"]')!;
|
||||
const form = panel.querySelector("form")!;
|
||||
const state = { form, positions: [] as Array<{ active: boolean; y: number }>, done: false };
|
||||
Object.assign(window, { __agentComposerTransition: state });
|
||||
await panel.evaluate(async (element) => {
|
||||
await Promise.allSettled(element.getAnimations({ subtree: true })
|
||||
.filter((animation) => animation.effect?.getComputedTiming().iterations !== Infinity)
|
||||
.map((animation) => animation.finished));
|
||||
});
|
||||
await page.evaluate(() => { Object.assign(window, { __agentComposerBefore: document.querySelector('aside[aria-label="Agent 命令面板"] form') }); });
|
||||
const before = (await form.boundingBox())!;
|
||||
await form.evaluate((element) => {
|
||||
const positions: number[] = [];
|
||||
Object.assign(window, { composerMotionPositions: positions });
|
||||
const start = performance.now();
|
||||
const sample = () => {
|
||||
state.positions.push({ active: panel.getAttribute("data-agent-starting") === "false", y: form.getBoundingClientRect().y });
|
||||
if (performance.now() - start < 1000) requestAnimationFrame(sample);
|
||||
else state.done = true;
|
||||
positions.push(element.getBoundingClientRect().y);
|
||||
if (performance.now() - start < 700) requestAnimationFrame(sample);
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
});
|
||||
await panel.getByRole("button", { name: "发送 Agent 指令" }).click();
|
||||
await expect(panel).toHaveAttribute("data-agent-starting", "false");
|
||||
await expect.poll(() => page.evaluate(() => (window as typeof window & {
|
||||
__agentComposerTransition: { done: boolean }
|
||||
}).__agentComposerTransition.done)).toBe(true);
|
||||
const afterTop = (await form.boundingBox())!.y;
|
||||
const result = await page.evaluate(() => {
|
||||
const state = (window as typeof window & { __agentComposerTransition: {
|
||||
form: HTMLFormElement; positions: Array<{ active: boolean; y: number }>
|
||||
} }).__agentComposerTransition;
|
||||
return { sameForm: state.form === document.querySelector('aside[aria-label="Agent 命令面板"] form'), positions: state.positions };
|
||||
});
|
||||
expect(result.sameForm).toBe(true);
|
||||
expect(afterTop).toBeGreaterThan(beforeTop);
|
||||
expect(result.positions.some((position) => position.active && position.y > beforeTop + 4 && position.y < afterTop - 4)).toBe(true);
|
||||
await expect(panel.locator(".agent-panel-user-message")).toBeVisible();
|
||||
await expect(panel.locator(".agent-panel-composer-motion")).toHaveCSS("transform", "none");
|
||||
const after = (await form.boundingBox())!;
|
||||
expect(after.y).toBeGreaterThan(before.y + 100);
|
||||
await expect(panel).toHaveAttribute("data-agent-centered", "false");
|
||||
const panelBox = (await panel.boundingBox())!;
|
||||
expect(Math.abs(after.y + after.height - (panelBox.y + panelBox.height - 8))).toBeLessThan(1);
|
||||
expect(Math.abs(after.height - before.height)).toBeLessThan(1);
|
||||
await expect.poll(() => page.evaluate(({ start, end }) =>
|
||||
(window as unknown as { composerMotionPositions: number[] }).composerMotionPositions
|
||||
.some((y) => y > start + 2 && y < end - 2),
|
||||
{ start: before.y, end: after.y })).toBe(true);
|
||||
expect(await page.evaluate(() => (window as typeof window & { __agentComposerBefore: Element }).__agentComposerBefore === document.querySelector('aside[aria-label="Agent 命令面板"] form'))).toBe(true);
|
||||
await expect(panel.locator("form")).toHaveCount(1);
|
||||
await panel.getByRole("button", { name: "新建 Agent 对话" }).click();
|
||||
await expect(panel.locator(".agent-panel-composer-motion")).toHaveCSS("transform", "none");
|
||||
await expectCenteredStart(panel);
|
||||
await expect(form.locator("textarea")).toHaveValue("");
|
||||
});
|
||||
|
||||
for (const width of [320, 375]) {
|
||||
test(`centered first question and restored persona fit at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 844 });
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "打开 Agent 面板" }).click();
|
||||
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
|
||||
await expect(panel.getByRole("heading", { name: "有什么可以帮你?" })).toBeVisible();
|
||||
await expect(panel.getByRole("group", { name: "推荐问题" }).getByRole("button")).toHaveCount(3);
|
||||
const form = (await panel.locator("form").boundingBox())!;
|
||||
expect(form.x).toBeGreaterThanOrEqual(0);
|
||||
expect(form.x + form.width).toBeLessThanOrEqual(width);
|
||||
expect(form.height).toBeLessThan(150);
|
||||
await expectCenteredStart(panel);
|
||||
await expect(panel.locator("header").getByLabel("Agent 状态")).toBeVisible();
|
||||
await preparePreview(page);
|
||||
await panel.screenshot({ path: `/tmp/nexttjwater-agent-start-preview/centered-${width}.png` });
|
||||
await panel.locator("textarea").fill("短屏下也能完成首次提问");
|
||||
await page.setViewportSize({ width, height: 480 });
|
||||
const send = panel.getByRole("button", { name: "发送 Agent 指令" });
|
||||
await send.scrollIntoViewIfNeeded();
|
||||
expect(await panel.evaluate((element) => element.scrollWidth <= element.clientWidth + 1)).toBe(true);
|
||||
const sendBox = (await send.boundingBox())!;
|
||||
expect(sendBox.y + sendBox.height).toBeLessThanOrEqual(480);
|
||||
await send.click();
|
||||
await expect(panel.locator(".agent-panel-user-message")).toHaveText("短屏下也能完成首次提问");
|
||||
await expect(panel).toHaveAttribute("data-agent-centered", "false");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -605,11 +605,11 @@ test("stream completion and the plan card remain positionally stable", async ({
|
||||
.poll(() =>
|
||||
scrollToLatestButton.evaluate((element) => getComputedStyle(element).backdropFilter)
|
||||
)
|
||||
.toContain("blur(");
|
||||
.toBe("none");
|
||||
await expect
|
||||
.poll(() => scrollToLatestButton.evaluate((element) => getComputedStyle(element).boxShadow))
|
||||
.not.toBe("none");
|
||||
const readAcrylicMaterial = (element: HTMLElement) => {
|
||||
const readOverlayMaterial = (element: HTMLElement) => {
|
||||
const style = getComputedStyle(element);
|
||||
|
||||
return {
|
||||
@@ -692,8 +692,8 @@ test("stream completion and the plan card remain positionally stable", async ({
|
||||
await expect(speechSelectionButton).toBeVisible();
|
||||
await expect(speechSelectionButton).toHaveAttribute("data-agent-presentation", "desktop-dock");
|
||||
await expect(speechSelectionButton).toHaveCSS("border-top-width", "0px");
|
||||
expect(await speechSelectionButton.evaluate(readAcrylicMaterial)).toEqual(
|
||||
await scrollToLatestButton.evaluate(readAcrylicMaterial)
|
||||
expect(await speechSelectionButton.evaluate(readOverlayMaterial)).toEqual(
|
||||
await scrollToLatestButton.evaluate(readOverlayMaterial)
|
||||
);
|
||||
await page.screenshot({ path: "test-results/agent-history-ui/desktop-conversation.png" });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { mockAgentApi } from "./support/mock-agent-api";
|
||||
import { mockWorkbenchContext } from "./support/mock-workbench-context";
|
||||
import { responseFixtureWriter } from "./support/responses-fixture";
|
||||
|
||||
async function setupTopics(page: Page) {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await mockWorkbenchContext(page);
|
||||
await mockAgentApi(page);
|
||||
const topics = [{ id: "10000000-0000-4000-8000-000000000001", name: "压力异常事件", createdAt: "2026-10-22T01:00:00Z", updatedAt: "2026-10-22T01:00:00Z" },
|
||||
{ id: "10000000-0000-4000-8000-000000000002", name: "阀门检修问题", createdAt: "2026-10-22T01:00:00Z", updatedAt: "2026-10-22T01:00:00Z" }];
|
||||
const sessions = [{ id: "topic-chat-a", title: "夜间压力复核", topic_id: topics[0].id, updated_at: Date.now(), created_at: Date.now(), purpose: "ordinary_chat" },
|
||||
{ id: "topic-chat-b", title: "检修记录核对", topic_id: topics[1].id, updated_at: Date.now() - 1000, created_at: Date.now() - 1000, purpose: "ordinary_chat" }];
|
||||
const metrics = { topicReads: 0, sentTopic: "" };
|
||||
await page.route(/\/api\/v1\/topics(?:\/[^?]+)?$/, (route) => {
|
||||
const request = route.request(); if (request.method() === "OPTIONS") return route.fallback();
|
||||
const id = new URL(request.url()).pathname.split("/").at(-1);
|
||||
const headers = { "Access-Control-Allow-Origin": "*" };
|
||||
if (request.method() === "GET") { metrics.topicReads++; return route.fulfill({ headers, json: { topics } }); }
|
||||
if (request.method() === "POST") {
|
||||
const topic = { id: "10000000-0000-4000-8000-000000000003", name: request.postDataJSON().name, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() };
|
||||
topics.push(topic); return route.fulfill({ headers, status: 201, json: topic });
|
||||
}
|
||||
if (request.method() === "PATCH") { const topic = topics.find((entry) => entry.id === id)!; topic.name = request.postDataJSON().name; return route.fulfill({ headers, json: topic }); }
|
||||
const index = topics.findIndex((entry) => entry.id === id); topics.splice(index, 1);
|
||||
return route.fulfill({ headers, status: 204 });
|
||||
});
|
||||
await page.route("**/api/v1/sessions?**", (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback();
|
||||
const query = new URL(route.request().url()).searchParams;
|
||||
const q = query.get("q") ?? "";
|
||||
const topicId = query.get("topic_id");
|
||||
return route.fulfill({ headers: { "Access-Control-Allow-Origin": "*" }, json: {
|
||||
sessions: sessions.map((session) => ({ ...session, topic_id: topics.some((topic) => topic.id === session.topic_id) ? session.topic_id : null,
|
||||
topic_name: topics.find((topic) => topic.id === session.topic_id)?.name,
|
||||
search_snippet: q && "南桥阀门夜间巡检记录".includes(q) ? "正文命中:南桥阀门夜间巡检记录" : undefined,
|
||||
})).filter((session) => (!q || session.title.includes(q) || session.search_snippet)
|
||||
&& (!topicId || (topicId === "unclassified" ? !session.topic_id : session.topic_id === topicId))), has_more: false, next_cursor: null,
|
||||
} });
|
||||
});
|
||||
await page.route(/\/api\/v1\/session\/topic-chat-[ab]\/topic$/, (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback();
|
||||
const id = new URL(route.request().url()).pathname.split("/").at(-2);
|
||||
const topicId = route.request().postDataJSON().topic_id;
|
||||
sessions.find((session) => session.id === id)!.topic_id = topicId;
|
||||
return route.fulfill({ headers: { "Access-Control-Allow-Origin": "*" }, json: { session_id: id, topic_id: topicId } });
|
||||
});
|
||||
await page.route("**/api/v1/responses", (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback();
|
||||
metrics.sentTopic = route.request().postDataJSON().tjwater.topic_id;
|
||||
const write = responseFixtureWriter("new-topic-conversation");
|
||||
return route.fulfill({ headers: { "Access-Control-Allow-Origin": "*" }, contentType: "text/event-stream", body: [
|
||||
{ type: "start", messageId: "topic-answer" }, { type: "text-start", id: "topic-text" },
|
||||
{ type: "text-delta", id: "topic-text", delta: "已按当前主题整理问题。" }, { type: "text-end", id: "topic-text" }, { type: "finish" },
|
||||
].map(write).join("") });
|
||||
});
|
||||
return metrics;
|
||||
}
|
||||
|
||||
test("topics organize chats while full-text history searches across all topics", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
const metrics = await setupTopics(page);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
|
||||
const topicTrigger = page.getByRole("button", { name: "选择主题" });
|
||||
await topicTrigger.click();
|
||||
await page.getByRole("menuitem", { name: "新建主题", exact: true }).click();
|
||||
const create = page.getByRole("dialog", { name: "新建主题", exact: true });
|
||||
await expect(create.getByRole("textbox", { name: "主题名称" })).toBeFocused();
|
||||
await expect(create).toHaveCSS("background-color", "rgb(255, 255, 255)");
|
||||
await expect(page.locator(".agent-history-overlay")).toHaveCSS("backdrop-filter", "none");
|
||||
await create.screenshot({ path: "test-results/agent-topics/create.png" });
|
||||
await create.getByRole("textbox", { name: "主题名称" }).fill("城南供水复盘");
|
||||
await create.getByRole("button", { name: "创建主题", exact: true }).click();
|
||||
await expect(create).toBeHidden();
|
||||
await expect(topicTrigger).toHaveText("城南供水复盘");
|
||||
await topicTrigger.click();
|
||||
await page.getByRole("menuitemradio", { name: "压力异常事件", exact: true }).click();
|
||||
await expect(topicTrigger).toHaveText("压力异常事件");
|
||||
await topicTrigger.click();
|
||||
await page.getByRole("menuitemradio", { name: "城南供水复盘", exact: true }).click();
|
||||
await expect(topicTrigger).toHaveText("城南供水复盘");
|
||||
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
|
||||
await panel.locator("textarea").fill("整理相关事件与待查问题");
|
||||
await panel.getByRole("button", { name: "发送 Agent 指令", exact: true }).click();
|
||||
await expect(panel.getByText("已按当前主题整理问题。", { exact: true })).toBeVisible();
|
||||
expect(metrics.sentTopic).toBe("10000000-0000-4000-8000-000000000003");
|
||||
await panel.locator("textarea").fill("这段草稿稍后继续整理");
|
||||
await panel.getByRole("button", { name: "新建 Agent 对话" }).click();
|
||||
await expect(panel.locator("textarea")).toHaveValue("");
|
||||
await expect(topicTrigger).toHaveText("城南供水复盘");
|
||||
await page.getByRole("button", { name: "打开 Agent 历史记录" }).click();
|
||||
await page.getByRole("dialog", { name: "历史对话", exact: true }).locator(".agent-history-session-trigger").filter({ hasText: "新对话" }).click();
|
||||
await expect(page.getByRole("dialog", { name: "历史对话", exact: true })).toBeHidden();
|
||||
await expect(panel.locator("textarea")).toHaveValue("这段草稿稍后继续整理");
|
||||
await page.getByRole("button", { name: "打开 Agent 历史记录" }).click();
|
||||
const history = page.getByRole("dialog", { name: "历史对话", exact: true });
|
||||
await history.getByRole("searchbox").fill("南桥阀门");
|
||||
await expect(history.locator(".agent-history-session-trigger")).toHaveCount(2);
|
||||
await expect(history.getByText("压力异常事件", { exact: true })).toBeVisible();
|
||||
await expect(history.getByText("阀门检修问题", { exact: true })).toBeVisible();
|
||||
await expect(history.getByText("正文命中:南桥阀门夜间巡检记录", { exact: true })).toHaveCount(2);
|
||||
await history.getByRole("button", { name: "会话操作:夜间压力复核" }).click();
|
||||
await page.getByRole("menuitem", { name: "移入主题", exact: true }).click();
|
||||
await history.getByRole("button", { name: "城南供水复盘", exact: true }).click();
|
||||
await expect(history.getByText("城南供水复盘", { exact: true })).toBeVisible();
|
||||
await history.getByRole("button", { name: "筛选历史主题" }).click();
|
||||
await expect(page.getByRole("menu")).toHaveCSS("background-color", "rgb(255, 255, 255)");
|
||||
await page.getByRole("menuitemradio", { name: "阀门检修问题", exact: true }).click();
|
||||
await expect(history.locator(".agent-history-session-trigger")).toHaveCount(1);
|
||||
await history.getByRole("button", { name: "筛选历史主题" }).click();
|
||||
await page.getByRole("menuitemradio", { name: "全部主题", exact: true }).click();
|
||||
await expect(history.locator(".agent-history-session-trigger")).toHaveCount(2);
|
||||
await history.screenshot({ path: "test-results/agent-topics/cross-topic-search.png" });
|
||||
await page.keyboard.press("Escape");
|
||||
await topicTrigger.click(); await page.getByRole("menuitem", { name: "管理主题", exact: true }).click();
|
||||
const manage = page.getByRole("dialog", { name: "管理主题", exact: true });
|
||||
await expect(manage.getByRole("button", { name: "新建主题", exact: true })).toBeFocused();
|
||||
await manage.screenshot({ path: "test-results/agent-topics/manage.png" });
|
||||
await manage.getByRole("button", { name: "重命名主题:城南供水复盘" }).click();
|
||||
await expect(page.getByRole("dialog", { name: "重命名主题", exact: true }).getByRole("textbox", { name: "主题名称" })).toBeFocused();
|
||||
await page.getByRole("dialog", { name: "重命名主题", exact: true }).getByRole("textbox", { name: "主题名称" }).fill("城南复盘与跟进");
|
||||
await page.getByRole("dialog", { name: "重命名主题", exact: true }).getByRole("button", { name: "保存主题" }).click();
|
||||
await expect(manage.getByText("城南复盘与跟进", { exact: true })).toBeVisible();
|
||||
await manage.getByRole("button", { name: "关闭主题窗口" }).click();
|
||||
await expect(topicTrigger).toHaveText("城南复盘与跟进");
|
||||
await topicTrigger.click(); await page.getByRole("menuitem", { name: "管理主题", exact: true }).click();
|
||||
await manage.getByRole("button", { name: "删除主题:城南复盘与跟进" }).click();
|
||||
await page.getByRole("dialog", { name: "删除主题", exact: true }).screenshot({ path: "test-results/agent-topics/delete.png" });
|
||||
await page.getByRole("dialog", { name: "删除主题", exact: true }).getByRole("button", { name: "确认删除主题" }).click();
|
||||
await expect(manage.getByText("城南复盘与跟进", { exact: true })).toHaveCount(0);
|
||||
await expect(manage.getByRole("button", { name: "关闭主题窗口" })).toBeEnabled();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(manage).toBeHidden();
|
||||
await expect(topicTrigger).toHaveText("未分类");
|
||||
const reads = metrics.topicReads;
|
||||
for (let i = 0; i < 3; i++) { await topicTrigger.click(); await expect(page.getByRole("menuitem", { name: "新建主题", exact: true })).toBeVisible(); await page.keyboard.press("Escape"); }
|
||||
expect(metrics.topicReads).toBe(reads);
|
||||
await page.getByRole("button", { name: "打开 Agent 历史记录" }).click();
|
||||
await expect(history.getByText("夜间压力复核", { exact: true })).toBeVisible();
|
||||
await expect(history.getByText("未分类", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("a topic can be created on a 320px mobile sheet", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 320, height: 812 });
|
||||
await setupTopics(page);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "打开 Agent 面板" }).click();
|
||||
await page.getByRole("button", { name: "选择主题" }).click();
|
||||
await page.getByRole("menuitem", { name: "新建主题", exact: true }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "新建主题", exact: true });
|
||||
await expect(dialog.getByRole("textbox", { name: "主题名称" })).toBeFocused();
|
||||
await dialog.getByRole("textbox", { name: "主题名称" }).fill("供水异常事件与问题跟踪");
|
||||
expect(await dialog.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true);
|
||||
await dialog.screenshot({ path: "test-results/agent-topics/mobile-create.png" });
|
||||
await dialog.getByRole("button", { name: "创建主题", exact: true }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(page.getByRole("button", { name: "选择主题" })).toHaveText("供水异常事件与问题跟踪");
|
||||
});
|
||||
|
||||
test("a topic created during a slow menu read remains available for switching", async ({ page }) => {
|
||||
await setupTopics(page);
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => { release = resolve; });
|
||||
let writes = 0;
|
||||
await page.route("**/api/v1/topics", async (route) => {
|
||||
if (route.request().method() === "GET") await gate;
|
||||
if (route.request().method() === "POST") writes++;
|
||||
await route.fallback();
|
||||
});
|
||||
try {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
|
||||
const trigger = page.getByRole("button", { name: "选择主题", exact: true });
|
||||
await trigger.click();
|
||||
await page.getByRole("menuitem", { name: "新建主题", exact: true }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "新建主题", exact: true });
|
||||
await dialog.getByRole("textbox", { name: "主题名称" }).fill("新建后持续跟踪");
|
||||
await dialog.getByRole("button", { name: "创建主题", exact: true }).click();
|
||||
await expect(dialog.locator('button[type="submit"]')).toHaveAttribute("aria-busy", "true");
|
||||
expect(writes).toBe(0);
|
||||
release();
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(trigger).toHaveText("新建后持续跟踪");
|
||||
await expect(page.locator("body")).not.toHaveCSS("pointer-events", "none");
|
||||
await trigger.click();
|
||||
await page.getByRole("menuitemradio", { name: "压力异常事件", exact: true }).click();
|
||||
await expect(trigger).toHaveText("压力异常事件");
|
||||
await trigger.click();
|
||||
await page.getByRole("menuitemradio", { name: "新建后持续跟踪", exact: true }).click();
|
||||
await expect(trigger).toHaveText("新建后持续跟踪");
|
||||
expect(writes).toBe(1);
|
||||
} finally { release(); }
|
||||
});
|
||||
|
||||
test("a failed topic creation keeps the name and allows retry", async ({ page }) => {
|
||||
await setupTopics(page);
|
||||
let failed = false;
|
||||
await page.route("**/api/v1/topics", (route) => {
|
||||
if (route.request().method() !== "POST" || failed) return route.fallback();
|
||||
failed = true;
|
||||
return route.fulfill({ status: 503, headers: { "Access-Control-Allow-Origin": "*" }, json: { message: "主题暂时无法保存,请重试" } });
|
||||
});
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
|
||||
const trigger = page.getByRole("button", { name: "选择主题", exact: true });
|
||||
await trigger.click();
|
||||
await page.getByRole("menuitem", { name: "新建主题", exact: true }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "新建主题", exact: true });
|
||||
await dialog.getByRole("textbox", { name: "主题名称" }).fill("保留输入并重试");
|
||||
await dialog.getByRole("button", { name: "创建主题", exact: true }).click();
|
||||
await expect(dialog.getByRole("alert")).toContainText("主题暂时无法保存");
|
||||
await expect(dialog.getByRole("textbox", { name: "主题名称" })).toHaveValue("保留输入并重试");
|
||||
await expect(dialog.getByRole("button", { name: "关闭主题窗口" })).toBeEnabled();
|
||||
await dialog.getByRole("button", { name: "创建主题", exact: true }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(trigger).toHaveText("保留输入并重试");
|
||||
await expect(trigger).toBeFocused();
|
||||
});
|
||||
@@ -10,7 +10,15 @@ const headers = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Head
|
||||
|
||||
async function setup(page: Page) {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await mockWorkbenchContext(page);
|
||||
await mockWorkbenchContext(page, undefined, {
|
||||
metadata: {
|
||||
project_id: TEST_PROJECT_ID, name: "奉贤演示项目", code: "tjwater_v2",
|
||||
gs_workspace: "tjwater_next", status: "active", project_role: "member",
|
||||
map_config: { place_search: { enabled: true, provider: "tianditu", admin_code: "310120", region: "奉贤区" } }
|
||||
},
|
||||
accessContext: { user_id: "operator", username: "operator", system_role: "user", is_system_admin: false,
|
||||
project_id: TEST_PROJECT_ID, project_role: "member", permissions: ["webgis.view"] }
|
||||
});
|
||||
await mockAgentApi(page);
|
||||
}
|
||||
|
||||
@@ -229,3 +237,91 @@ test("place search reports errors and distinguishes an empty Fengxian search", a
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(320);
|
||||
await page.screenshot({ path: test.info().outputPath("place-empty-320.png") });
|
||||
});
|
||||
|
||||
async function exposedMapPoint(page: Page) {
|
||||
return page.locator(".maplibregl-canvas").evaluate((canvas) => {
|
||||
const bounds = canvas.getBoundingClientRect();
|
||||
for (const yFraction of [0.3, 0.35, 0.25, 0.45, 0.65]) {
|
||||
for (const xFraction of [0.5, 0.3, 0.65, 0.15]) {
|
||||
const x = bounds.left + bounds.width * xFraction;
|
||||
const y = bounds.top + bounds.height * yFraction;
|
||||
if (document.elementFromPoint(x, y) === canvas) return { x: bounds.width * xFraction, y: bounds.height * yFraction };
|
||||
}
|
||||
}
|
||||
throw new Error("No exposed map point available");
|
||||
});
|
||||
}
|
||||
|
||||
for (const width of [1280, 320]) {
|
||||
test(`map point lookup shows address without moving the camera at ${width}px`, async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await setup(page);
|
||||
let lastPoint: { longitude: number; latitude: number } | null = null;
|
||||
let pointLookups = 0;
|
||||
await page.route("**/api/v1/reverse-geocoding-requests", async (route) => {
|
||||
if (route.request().method() === "OPTIONS") { await route.fulfill({ status: 204, headers }); return; }
|
||||
expect(route.request().headers()["x-project-id"]).toBe(TEST_PROJECT_ID);
|
||||
pointLookups += 1;
|
||||
lastPoint = route.request().postDataJSON();
|
||||
await route.fulfill({ headers, json: {
|
||||
...first, id: "map-point", name: "南桥镇", address: "上海市奉贤区南桥镇南桥路", ...lastPoint,
|
||||
category: "附近地名"
|
||||
} });
|
||||
});
|
||||
const panel = await openSearch(page);
|
||||
const picker = panel.getByRole("button", { name: "从地图取点查找地名" });
|
||||
await picker.click();
|
||||
await expect(picker).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(panel.getByRole("status")).toContainText("点击地图查询地名");
|
||||
const canvas = page.locator(".maplibregl-canvas");
|
||||
await expect(canvas).toHaveCSS("cursor", "crosshair");
|
||||
const before = await page.evaluate(() => ({ center: window.__waterNetworkMap!.getCenter().toArray(), zoom: window.__waterNetworkMap!.getZoom() }));
|
||||
await canvas.click({ position: await exposedMapPoint(page) });
|
||||
const selected = panel.getByRole("region", { name: "定位结果" });
|
||||
await expect(selected).toContainText("上海市奉贤区南桥镇南桥路");
|
||||
expect(lastPoint).not.toBeNull();
|
||||
const visibleCoordinates = await selected.getByLabel("经纬度坐标").locator("dd").evaluate((element) => {
|
||||
const scrollArea = element.closest(".overflow-y-auto")!;
|
||||
return element.getBoundingClientRect().bottom <= scrollArea.getBoundingClientRect().bottom;
|
||||
});
|
||||
expect(visibleCoordinates).toBe(true);
|
||||
await expect(page.locator('[data-geocoding-marker="true"]')).toHaveCount(1);
|
||||
const after = await page.evaluate(() => ({ center: window.__waterNetworkMap!.getCenter().toArray(), zoom: window.__waterNetworkMap!.getZoom() }));
|
||||
expect(after).toEqual(before);
|
||||
await expect(panel.getByRole("button", { name: "更换位置" })).toHaveCount(0);
|
||||
await panel.screenshot({ path: testInfo.outputPath(`map-point-${width}.png`), animations: "disabled" });
|
||||
await expect(picker).toHaveAttribute("aria-pressed", "false");
|
||||
await expect(canvas).not.toHaveCSS("cursor", "crosshair");
|
||||
await expect(selected).toBeVisible();
|
||||
await canvas.click({ position: await exposedMapPoint(page) });
|
||||
expect(pointLookups).toBe(1);
|
||||
await selected.getByRole("button", { name: "清除结果" }).click();
|
||||
await expect(page.locator('[data-geocoding-marker="true"]')).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
|
||||
test("map point lookup recovers from an out of scope point and stops when closing", async ({ page }) => {
|
||||
await setup(page);
|
||||
let calls = 0;
|
||||
await page.route("**/api/v1/reverse-geocoding-requests", async (route) => {
|
||||
if (route.request().method() === "OPTIONS") { await route.fulfill({ status: 204, headers }); return; }
|
||||
calls += 1;
|
||||
await route.fulfill({ headers, status: calls === 1 ? 422 : 200, json: calls === 1
|
||||
? { detail: "取点位置不在当前项目的地名搜索范围内(奉贤区)" }
|
||||
: { ...first, ...route.request().postDataJSON(), id: "map-retry" } });
|
||||
});
|
||||
const panel = await openSearch(page);
|
||||
await panel.getByRole("button", { name: "从地图取点查找地名" }).click();
|
||||
const canvas = page.locator(".maplibregl-canvas");
|
||||
await canvas.click({ position: { x: 450, y: 170 } });
|
||||
await expect(panel.getByRole("alert")).toContainText("不在当前项目");
|
||||
await expect(panel.getByRole("button", { name: "从地图取点查找地名" })).toHaveAttribute("aria-pressed", "true");
|
||||
await canvas.click({ position: { x: 440, y: 180 } });
|
||||
await expect(panel.getByRole("region", { name: "定位结果" })).toContainText("上海市奉贤区南桥路1号");
|
||||
await expect(panel.getByRole("button", { name: "从地图取点查找地名" })).toHaveAttribute("aria-pressed", "false");
|
||||
await page.getByRole("button", { name: "地址定位:查询地址并定位到地图" }).click();
|
||||
await expect(panel).toBeHidden();
|
||||
await expect(canvas).not.toHaveCSS("cursor", "crosshair");
|
||||
await canvas.click({ position: { x: 430, y: 190 } });
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
|
||||
@@ -79,22 +79,26 @@ test("证据提问在预览、展开及更多依据中使用统一实体按钮",
|
||||
}
|
||||
});
|
||||
|
||||
async function expectCenteredReference(panel: Locator) {
|
||||
async function expectTaskReferenceLayout(panel: Locator) {
|
||||
await expect(panel).toHaveAttribute("data-agent-starting", "true");
|
||||
await expect(panel.getByRole("group", { name: "对话引用" })).toBeVisible();
|
||||
await expect(panel.getByRole("group", { name: "推荐问题" })).toHaveCount(0);
|
||||
await expect(panel.locator("form")).toHaveCount(1);
|
||||
// Existing task state keeps the normal reading area, as required by agent-chat-ui-design.md.
|
||||
const status = panel.getByRole("region", { name: "任务协作状态" });
|
||||
await expect(status).toBeVisible();
|
||||
await expect.poll(async () => {
|
||||
const panelBox = (await panel.boundingBox())!;
|
||||
const header = (await panel.locator("header").boundingBox())!;
|
||||
const composer = (await panel.locator("form").boundingBox())!;
|
||||
// The mobile header extends above the panel to cover the drawer handle.
|
||||
const center = (header.y + header.height + panelBox.y + panelBox.height) / 2;
|
||||
return Math.abs(composer.y + composer.height / 2 - center);
|
||||
}).toBeLessThan(2);
|
||||
return panelBox.y + panelBox.height - composer.y - composer.height;
|
||||
}).toBeLessThan(32);
|
||||
const statusBox = (await status.boundingBox())!;
|
||||
const composerBox = (await panel.locator("form").boundingBox())!;
|
||||
expect(composerBox.y).toBeGreaterThanOrEqual(statusBox.y + statusBox.height);
|
||||
}
|
||||
|
||||
test("单一协作入口展示任务引用,保留草稿且仅手动发送,并适配窄屏", async ({ page }, testInfo) => {
|
||||
test.setTimeout(60_000);
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await mockWorkbenchContext(page);
|
||||
await mockAgentApi(page, { sessions: [{ session_id: "ordinary-main", title: "供水核查主聊天", purpose: "ordinary_chat", created_at: now, updated_at: now }] });
|
||||
@@ -124,13 +128,13 @@ test("单一协作入口展示任务引用,保留草稿且仅手动发送,
|
||||
const agent = page.getByRole("complementary", { name: "Agent 命令面板" });
|
||||
await expect(agent.getByRole("heading", { name: "我已就绪,请描述任务" })).toHaveCount(0);
|
||||
await agent.getByRole("button", { name: "打开 Agent 历史记录" }).click();
|
||||
await agent.getByRole("button", { name: /供水核查主聊天/ }).click();
|
||||
await page.getByRole("dialog", { name: "历史对话" }).getByRole("button", { name: /^供水核查主聊天/ }).click();
|
||||
const prompt = agent.locator("textarea");
|
||||
await prompt.fill("保留的现场补充草稿");
|
||||
await page.getByRole("button", { name: "工况任务", exact: true }).click();
|
||||
const feed = page.getByRole("region", { name: "工况任务", exact: true });
|
||||
const expand = feed.getByRole("button", { name: "展开工况任务" });
|
||||
if (await expand.isVisible()) await expand.click();
|
||||
await expect(feed).toBeVisible();
|
||||
await feed.getByRole("button", { name: "展开工况任务" }).click();
|
||||
await feed.getByRole("button", { name: /调查任务/ }).click();
|
||||
await feed.getByText("居民异味投诉调查", { exact: true }).first().click();
|
||||
await expect(feed.getByText("16:00", { exact: true })).toBeVisible();
|
||||
@@ -158,7 +162,7 @@ test("单一协作入口展示任务引用,保留草稿且仅手动发送,
|
||||
await expect(prompt).toHaveValue("保留的现场补充草稿\n\n请解释“上游监测数据质量已核对”这条证据的范围、来源和判断影响。");
|
||||
await expect(prompt).toBeFocused();
|
||||
await expect(reference).toContainText("已引用证据");
|
||||
await expectCenteredReference(agent);
|
||||
await expectTaskReferenceLayout(agent);
|
||||
expect(submitted).toBe(0);
|
||||
await agent.screenshot({ path: testInfo.outputPath("investigation-reference-desktop.png") });
|
||||
await feed.getByRole("button", { name: /定时任务/ }).click();
|
||||
@@ -167,10 +171,10 @@ test("单一协作入口展示任务引用,保留草稿且仅手动发送,
|
||||
const mobileAgent = page.getByRole("complementary", { name: "Agent 命令面板" });
|
||||
await expect(mobileAgent.getByRole("heading", { name: "我已就绪,请描述任务" })).toHaveCount(0);
|
||||
await expect(mobileAgent.getByRole("group", { name: "对话引用" })).toContainText("居民异味投诉调查");
|
||||
await expectCenteredReference(mobileAgent);
|
||||
await expectTaskReferenceLayout(mobileAgent);
|
||||
await mobileAgent.screenshot({ path: testInfo.outputPath("investigation-reference-narrow.png") });
|
||||
await page.setViewportSize({ width: 320, height: 900 });
|
||||
await expectCenteredReference(mobileAgent);
|
||||
await expectTaskReferenceLayout(mobileAgent);
|
||||
await mobileAgent.screenshot({ path: testInfo.outputPath("investigation-reference-320.png") });
|
||||
expect(await mobileAgent.evaluate((element) => element.scrollWidth <= element.clientWidth + 1)).toBe(true);
|
||||
await page.setViewportSize({ width: 375, height: 900 });
|
||||
@@ -194,10 +198,10 @@ test("单一协作入口展示任务引用,保留草稿且仅手动发送,
|
||||
return panelBox.y + panelBox.height - composer.y - composer.height;
|
||||
}).toBeLessThan(32);
|
||||
await expect(mobileAgent.getByRole("button", { name: "停止 Agent 分析" })).toHaveCount(0);
|
||||
await mobileAgent.getByRole("button", { name: "打开 Agent 历史记录" }).click();
|
||||
await mobileAgent.getByRole("button", { name: /供水核查主聊天/ }).click();
|
||||
await expect(mobileAgent.getByRole("group", { name: "对话引用" })).toHaveCount(0);
|
||||
incoming = true;
|
||||
await mobileAgent.getByRole("button", { name: "打开 Agent 历史记录" }).click();
|
||||
await page.getByRole("dialog", { name: "历史对话" }).getByRole("button", { name: /^供水核查主聊天/ }).click();
|
||||
await expect(mobileAgent.getByRole("group", { name: "对话引用" })).toHaveCount(0);
|
||||
await expect(mobileAgent.getByText("已核对上游监测,请补充现场采样记录。")).toBeVisible();
|
||||
await expect(mobileAgent.getByText("待接收", { exact: true })).toBeVisible();
|
||||
await expect(mobileAgent.getByRole("button", { name: "停止 Agent 分析" })).toHaveCount(0);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { mockAgentApi } from "./support/mock-agent-api";
|
||||
|
||||
test.describe("外围工单测试", () => {
|
||||
test.describe("外围系统", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockAgentApi(page);
|
||||
await page.route("**/runtime-config.js", (route) =>
|
||||
@@ -96,8 +96,8 @@ test.describe("外围工单测试", () => {
|
||||
});
|
||||
});
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "外围样板管理" }).click();
|
||||
const panel = page.getByRole("complementary", { name: "外围工单测试" });
|
||||
await page.getByRole("button", { name: "外围系统", exact: true }).click();
|
||||
const panel = page.getByRole("complementary", { name: "外围系统" });
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(panel.getByText("工单接口测试", { exact: true })).toBeVisible();
|
||||
await expect(panel.getByText("历史监测回放", { exact: true })).toHaveCount(0);
|
||||
@@ -137,11 +137,11 @@ test.describe("外围工单测试", () => {
|
||||
if (request.url().includes("/api/sample/v1")) peripheralRequests.push(request.url());
|
||||
});
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "外围样板管理" }).click();
|
||||
await expect(page.getByRole("complementary", { name: "外围工单测试" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "外围系统", exact: true }).click();
|
||||
await expect(page.getByRole("complementary", { name: "外围系统" })).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.getByRole("complementary", { name: "外围工单测试" })
|
||||
.getByRole("complementary", { name: "外围系统" })
|
||||
.getByText("仅用于验证外围工单接口与同步过程,不代表真实现场处置", { exact: true })
|
||||
.last()
|
||||
).toBeVisible();
|
||||
@@ -149,6 +149,129 @@ test.describe("外围工单测试", () => {
|
||||
expect(peripheralRequests).toEqual([]);
|
||||
});
|
||||
|
||||
test("SCADA 系统按当前项目读取最新记录并设定模拟异常", async ({ page }, testInfo) => {
|
||||
const latestRequests: string[] = [];
|
||||
const simulatedWrites: Array<Record<string, unknown>> = [];
|
||||
const projectId = "324ff7ee-c2d4-5184-a84b-48fb14a2933e";
|
||||
await page.route("**/runtime-config.js", (route) =>
|
||||
route.fulfill({
|
||||
contentType: "text/javascript",
|
||||
body: `globalThis.__TJWATER_CONFIG__ = ${JSON.stringify({
|
||||
TJWATER_AUTH_MODE: "disabled",
|
||||
TJWATER_SERVER_API_BASE_URL: "http://server.test",
|
||||
TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL: "http://peripheral.test"
|
||||
})};`
|
||||
})
|
||||
);
|
||||
await page.route("http://peripheral.test/api/sample/v1/**", (route) => {
|
||||
const request = route.request();
|
||||
const path = new URL(request.url()).pathname;
|
||||
if (path.endsWith("/context")) return route.fulfill({ json: {
|
||||
protocol_version: "tjwater-peripheral-sample@1", origin: "synthetic", project_id: projectId,
|
||||
user_id: "50000000-0000-4000-8000-000000000001", can_write: true,
|
||||
scenarios: ["normal"], managed_resources: { work_orders: "available", test_data: "available" },
|
||||
state_transitions: { pending: [], processing: [], pending_review: [], completed: [] }
|
||||
} });
|
||||
if (path.endsWith("/test-data") && request.method() === "GET") {
|
||||
return route.fulfill({ json: { items: [], next_cursor: null } });
|
||||
}
|
||||
if (path.endsWith("/test-data") && request.method() === "POST") {
|
||||
const body = request.postDataJSON() as Record<string, unknown>;
|
||||
simulatedWrites.push(body);
|
||||
return route.fulfill({ status: 201, json: {
|
||||
...body,
|
||||
record_id: "50000000-0000-4000-8000-000000000017",
|
||||
project_id: projectId,
|
||||
revision: 1,
|
||||
origin: "synthetic",
|
||||
created_at: "2026-10-22T02:03:01Z",
|
||||
updated_at: "2026-10-22T02:03:01Z",
|
||||
created_by: "tester",
|
||||
updated_by: "tester"
|
||||
} });
|
||||
}
|
||||
if (path.endsWith("/work-orders")) {
|
||||
return route.fulfill({ json: { items: [], next_cursor: null } });
|
||||
}
|
||||
return route.fulfill({ status: 404, json: {} });
|
||||
});
|
||||
await page.route("http://server.test/api/v1/timeseries/scada-readings/latest?**", (route) => {
|
||||
latestRequests.push(route.request().headers()["x-project-id"] ?? "");
|
||||
return route.fulfill({
|
||||
json: {
|
||||
items: [
|
||||
{
|
||||
device_id: "PRESSURE-17",
|
||||
device_type: "pressure",
|
||||
measurement_unit: "MPa",
|
||||
transmission_mode: "realtime",
|
||||
transmission_frequency: "15min",
|
||||
observed_at: "2026-10-22T01:02:03Z",
|
||||
monitored_value: 0.3172,
|
||||
cleaned_value: 0.315
|
||||
},
|
||||
{
|
||||
device_id: "FLOW-09",
|
||||
device_type: "pipe_flow",
|
||||
measurement_unit: "m³/h",
|
||||
transmission_mode: "realtime",
|
||||
transmission_frequency: "15min",
|
||||
observed_at: null,
|
||||
monitored_value: null,
|
||||
cleaned_value: null
|
||||
}
|
||||
],
|
||||
total: 2,
|
||||
limit: 500,
|
||||
offset: 0,
|
||||
observed_count: 1,
|
||||
latest_observed_at: "2026-10-22T01:02:03Z"
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "外围系统", exact: true }).click();
|
||||
const panel = page.getByRole("complementary", { name: "外围系统" });
|
||||
await panel.getByRole("tab", { name: "SCADA 系统" }).click();
|
||||
|
||||
await expect(panel.getByRole("table", { name: "SCADA 最新数据表" })).toBeVisible();
|
||||
await expect(panel.getByText("PRESSURE-17", { exact: true })).toBeVisible();
|
||||
await expect(panel.getByText("0.3172 MPa", { exact: true })).toBeVisible();
|
||||
await expect(panel.getByText("0.315 MPa", { exact: true })).toBeVisible();
|
||||
await expect(panel.getByText("FLOW-09", { exact: true })).toBeVisible();
|
||||
await expect(panel.getByText("无记录", { exact: true })).toBeVisible();
|
||||
await expect(panel.getByLabel("SCADA 最新数据概览")).toContainText(/1\s*\/\s*2 个设备/);
|
||||
expect(latestRequests).toEqual(["324ff7ee-c2d4-5184-a84b-48fb14a2933e"]);
|
||||
|
||||
await panel.getByRole("textbox", { name: "筛选 SCADA 设备" }).fill("pressure");
|
||||
await expect(panel.getByText("PRESSURE-17", { exact: true })).toBeVisible();
|
||||
await expect(panel.getByText("FLOW-09", { exact: true })).toHaveCount(0);
|
||||
await panel.getByRole("textbox", { name: "筛选 SCADA 设备" }).fill("");
|
||||
|
||||
await panel.getByRole("button", { name: "设定读数" }).click();
|
||||
for (const width of [1280, 375, 320]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await panel.getByPlaceholder("输入需要演练的异常值").scrollIntoViewIfNeeded();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
|
||||
await testInfo.attach(`scada-peripheral-anomaly-${width}`, {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png"
|
||||
});
|
||||
}
|
||||
await panel.getByPlaceholder("输入需要演练的异常值").fill("0.08");
|
||||
await panel.getByPlaceholder("例如:压力突降,偏离近一小时稳定区间").fill("压力突降");
|
||||
await panel.getByRole("button", { name: "保存模拟读数", exact: true }).click();
|
||||
await expect(panel.getByLabel("SCADA 模拟异常列表")).toContainText("PRESSURE-17");
|
||||
await expect(panel.getByLabel("SCADA 模拟异常列表")).toContainText("0.08 MPa");
|
||||
expect(simulatedWrites[0]).toMatchObject({
|
||||
point_id: "PRESSURE-17",
|
||||
value: 0.08,
|
||||
quality: "valid",
|
||||
notes: "[SCADA_ANOMALY] 压力突降"
|
||||
});
|
||||
});
|
||||
|
||||
test("管理面板打开时阻止地图接收鼠标行为", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
@@ -194,10 +317,10 @@ test.describe("外围工单测试", () => {
|
||||
.toEqual([]);
|
||||
};
|
||||
|
||||
await page.getByRole("button", { name: "外围样板管理" }).click();
|
||||
await expect(page.getByRole("complementary", { name: "外围工单测试" })).toBeVisible();
|
||||
await expectMapInteractionBlocked("外围工单测试");
|
||||
await page.getByRole("button", { name: "外围样板管理" }).click();
|
||||
await page.getByRole("button", { name: "外围系统", exact: true }).click();
|
||||
await expect(page.getByRole("complementary", { name: "外围系统" })).toBeVisible();
|
||||
await expectMapInteractionBlocked("外围系统");
|
||||
await page.getByRole("button", { name: "外围系统", exact: true }).click();
|
||||
|
||||
await page.getByRole("button", { name: "项目分析成果" }).click();
|
||||
await expect(page.getByRole("complementary", { name: "项目分析成果" })).toBeVisible();
|
||||
@@ -241,8 +364,8 @@ test.describe("外围工单测试", () => {
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "外围样板管理" }).click();
|
||||
const panel = page.getByRole("complementary", { name: "外围工单测试" });
|
||||
await page.getByRole("button", { name: "外围系统" }).click();
|
||||
const panel = page.getByRole("complementary", { name: "外围系统" });
|
||||
await expect(panel.getByText("测试工单存储待供给", { exact: true })).toBeVisible();
|
||||
await expect(panel.getByText("测试数据存储待供给", { exact: true })).toHaveCount(0);
|
||||
await expect(panel.getByText("历史监测回放", { exact: true })).toHaveCount(0);
|
||||
@@ -294,8 +417,8 @@ test.describe("外围工单测试", () => {
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "外围样板管理" }).click();
|
||||
const panel = page.getByRole("complementary", { name: "外围工单测试" });
|
||||
await page.getByRole("button", { name: "外围系统" }).click();
|
||||
const panel = page.getByRole("complementary", { name: "外围系统" });
|
||||
await expect(panel.getByText("工单接口测试", { exact: true })).toBeVisible();
|
||||
await expect(panel.getByLabel("预约工单列表")).toContainText("暂无预约工单");
|
||||
await expect(panel.getByLabel("投诉工单列表")).toContainText("暂无投诉工单");
|
||||
@@ -567,8 +690,8 @@ test.describe("外围工单测试", () => {
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "外围样板管理" }).click();
|
||||
const panel = page.getByRole("complementary", { name: "外围工单测试" });
|
||||
await page.getByRole("button", { name: "外围系统" }).click();
|
||||
const panel = page.getByRole("complementary", { name: "外围系统" });
|
||||
await panel.getByRole("button", { name: "投诉与爆管上报" }).click();
|
||||
await panel.getByRole("textbox", { name: "事件位置" }).fill("演示小区");
|
||||
await panel.getByRole("textbox", { name: "现场描述" }).fill("多户居民反映晨间用水有异味");
|
||||
@@ -681,10 +804,10 @@ test.describe("外围工单测试", () => {
|
||||
test("在 375px 视口内完整呈现面板", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 720 });
|
||||
await page.goto("/");
|
||||
const launcher = page.getByRole("button", { name: "外围样板管理" });
|
||||
const launcher = page.getByRole("button", { name: "外围系统" });
|
||||
await launcher.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
const panel = page.getByRole("complementary", { name: "外围工单测试" });
|
||||
const panel = page.getByRole("complementary", { name: "外围系统" });
|
||||
await expect(panel).toBeVisible();
|
||||
const box = await panel.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { mockAgentApi } from "./support/mock-agent-api";
|
||||
import { mockWorkbenchContext } from "./support/mock-workbench-context";
|
||||
import { TEST_PROJECT_ID } from "./support/mock-scada-api";
|
||||
|
||||
const FENGYANG_ID = "b962c44e-8433-4a10-81e9-a64b70ac1152";
|
||||
const projects = [
|
||||
{ project_id: TEST_PROJECT_ID, name: "奉贤演示项目", code: "tjwater_v2", gs_workspace: "tjwater_next", status: "active" },
|
||||
{ project_id: FENGYANG_ID, name: "凤阳(新版)", code: "fengyang_v2", gs_workspace: "fengyang_v2", status: "active" }
|
||||
];
|
||||
|
||||
async function prepare(page: Page, denyFengyang = false, configureSearch = true) {
|
||||
await mockWorkbenchContext(page);
|
||||
await mockAgentApi(page);
|
||||
await page.route("https://tjwater-api.invalid/**", async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method() === "OPTIONS") return route.fallback();
|
||||
const path = new URL(request.url()).pathname;
|
||||
const projectId = request.headers()["x-project-id"];
|
||||
const project = projects.find((item) => item.project_id === projectId);
|
||||
const headers = { "Access-Control-Allow-Origin": "*" };
|
||||
if (path === "/api/v1/projects") return route.fulfill({ headers, json: { items: projects, total: 2, limit: 1000, offset: 0 } });
|
||||
if (!project) return route.fallback();
|
||||
if (path === "/api/v1/projects/current/metadata") return route.fulfill({ headers,
|
||||
status: denyFengyang && projectId === FENGYANG_ID ? 403 : 200,
|
||||
json: { ...project, map_config: configureSearch ? { place_search: {
|
||||
enabled: true, provider: "tianditu", admin_code: projectId === FENGYANG_ID ? "341126" : "310120",
|
||||
region: projectId === FENGYANG_ID ? "凤阳县" : "奉贤区"
|
||||
} } : {}, map_extent: { bbox: projectId === FENGYANG_ID
|
||||
? [13043968, 3846034, 13127803, 3889110]
|
||||
: [13508802, 3608164, 13555651, 3633686] }, project_role: "member" }
|
||||
});
|
||||
if (path === "/api/v1/access-context") return route.fulfill({ headers, json: {
|
||||
user_id: "operator", username: "project.operator", system_role: "user", is_system_admin: false,
|
||||
project_id: projectId, project_role: "member", permissions: ["webgis.view"]
|
||||
} });
|
||||
return route.fallback();
|
||||
});
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
}
|
||||
|
||||
async function openSwitcher(page: Page, input: "pointer" | "keyboard" = "pointer") {
|
||||
const userMenu = page.getByRole("button", { name: "打开用户菜单" });
|
||||
if (input === "keyboard") {
|
||||
await userMenu.press("Enter");
|
||||
await page.getByRole("menuitem", { name: "切换项目" }).press("Enter");
|
||||
} else {
|
||||
await userMenu.click();
|
||||
await page.getByRole("menuitem", { name: "切换项目" }).click();
|
||||
}
|
||||
const dialog = page.getByRole("dialog", { name: "切换项目" });
|
||||
await expect(dialog).toBeVisible();
|
||||
return dialog;
|
||||
}
|
||||
|
||||
async function chooseProject(page: Page, name: string) {
|
||||
await page.getByRole("combobox", { name: "项目环境" }).click();
|
||||
await page.getByRole("option").filter({ has: page.getByText(name, { exact: true }) }).click();
|
||||
}
|
||||
|
||||
for (const viewport of [{ width: 1440, height: 900 }, { width: 375, height: 812 }, { width: 320, height: 740 }]) {
|
||||
test(`switches projects through the dialog and select at ${viewport.width}px`, async ({ page }, testInfo) => {
|
||||
// This scenario boots the map three times and checks both dialog states.
|
||||
test.setTimeout(60_000);
|
||||
// Empty mock sources emit unrelated degradation notices; exclude them from design captures.
|
||||
const screenshotStyle = "[data-sonner-toaster] { visibility: hidden; }";
|
||||
await page.setViewportSize(viewport);
|
||||
await prepare(page);
|
||||
const pageErrors: string[] = [];
|
||||
page.on("pageerror", (error) => pageErrors.push(error.message));
|
||||
const requests: { url: string; project: string | undefined }[] = [];
|
||||
page.on("request", (request) => requests.push({ url: request.url(), project: request.headers()["x-project-id"] }));
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
const dialog = await openSwitcher(page);
|
||||
await expect(dialog.getByRole("combobox")).toContainText("奉贤演示项目");
|
||||
await expect(dialog.getByRole("heading", { name: "切换项目" })).toBeVisible();
|
||||
await expect(dialog.getByRole("button", { name: "进入项目", exact: true })).toBeDisabled();
|
||||
await chooseProject(page, "凤阳(新版)");
|
||||
expect(requests.some((request) => request.project === FENGYANG_ID)).toBe(false);
|
||||
await dialog.screenshot({ path: testInfo.outputPath(`project-draft-${viewport.width}.png`), animations: "disabled", style: screenshotStyle });
|
||||
await dialog.getByRole("button", { name: "进入项目", exact: true }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect.poll(() => requests.some((request) => request.url.includes("/scada-devices") && request.project === FENGYANG_ID), { timeout: 10000 }).toBe(true);
|
||||
await expect.poll(() => requests.some((request) => request.url.includes("/api/v1/sessions") && request.project === FENGYANG_ID)).toBe(true);
|
||||
await expect.poll(() => page.evaluate(() => {
|
||||
const source = window.__waterNetworkMap?.getSource("pipes") as { tiles?: string[] } | undefined;
|
||||
return source?.tiles?.[0] ?? "";
|
||||
})).toContain("fengyang_v2:pipes");
|
||||
await expect.poll(() => page.evaluate(() => window.__waterNetworkMap?.getCenter().lng ?? 0), { timeout: 15_000 }).toBeGreaterThan(117);
|
||||
await expect.poll(() => page.evaluate(() => window.__waterNetworkMap?.getCenter().lng ?? 999), { timeout: 15_000 }).toBeLessThan(119);
|
||||
const currentDialog = await openSwitcher(page);
|
||||
await expect(currentDialog.getByRole("combobox")).toContainText("凤阳(新版)");
|
||||
const bounds = await currentDialog.boundingBox();
|
||||
expect(bounds).not.toBeNull();
|
||||
expect(bounds!.x).toBeGreaterThanOrEqual(15);
|
||||
expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(viewport.width - 15);
|
||||
await page.screenshot({ path: testInfo.outputPath(`project-dialog-${viewport.width}.png`), animations: "disabled", style: screenshotStyle });
|
||||
await currentDialog.screenshot({ path: testInfo.outputPath(`project-dialog-detail-${viewport.width}.png`), animations: "disabled", style: screenshotStyle });
|
||||
await currentDialog.getByRole("combobox").click();
|
||||
await expect(page.getByRole("listbox")).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath(`project-options-${viewport.width}.png`), animations: "disabled", style: screenshotStyle });
|
||||
await page.keyboard.press("Escape");
|
||||
await page.keyboard.press("Escape");
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
const restoredDialog = await openSwitcher(page);
|
||||
await expect(restoredDialog.getByRole("combobox")).toContainText("凤阳(新版)");
|
||||
await chooseProject(page, "奉贤演示项目");
|
||||
await restoredDialog.getByRole("button", { name: "进入项目", exact: true }).click();
|
||||
await expect.poll(() => page.evaluate(() => {
|
||||
const source = window.__waterNetworkMap?.getSource("pipes") as { tiles?: string[] } | undefined;
|
||||
return source?.tiles?.[0] ?? "";
|
||||
})).toContain("tjwater_next:pipes");
|
||||
expect(pageErrors).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test("keeps the dialog and current project when target access fails", async ({ page }) => {
|
||||
await prepare(page, true);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
const dialog = await openSwitcher(page);
|
||||
await dialog.getByRole("combobox").press("ArrowDown");
|
||||
await page.getByRole("option").filter({ has: page.getByText("凤阳(新版)", { exact: true }) }).focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await dialog.getByRole("button", { name: "进入项目", exact: true }).focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(dialog.getByRole("alert")).toContainText("目标项目暂时无法访问");
|
||||
await expect(dialog.getByText("当前项目:奉贤演示项目")).toBeVisible();
|
||||
await expect(dialog.getByRole("combobox")).toContainText("凤阳(新版)");
|
||||
});
|
||||
|
||||
for (const reducedMotion of ["reduce", "no-preference"] as const) {
|
||||
test(`discards a draft selection and restores focus with motion=${reducedMotion}`, async ({ page }) => {
|
||||
await prepare(page);
|
||||
await page.emulateMedia({ reducedMotion });
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
const dialog = await openSwitcher(page, reducedMotion === "reduce" ? "keyboard" : "pointer");
|
||||
await expect(dialog.getByRole("combobox")).toBeFocused();
|
||||
await expect(dialog.getByRole("button", { name: "关闭项目切换" })).not.toBeFocused();
|
||||
await expect(page.getByRole("listbox")).toBeHidden();
|
||||
await expect(dialog).toHaveCSS("opacity", "1");
|
||||
await expect(dialog).toHaveCSS("animation-name", "none");
|
||||
await page.keyboard.press("Shift+Tab");
|
||||
await expect(dialog.getByRole("button", { name: "关闭项目切换" })).toBeFocused();
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(dialog.getByRole("combobox")).toBeFocused();
|
||||
await dialog.getByRole("combobox").click();
|
||||
await expect(page.getByRole("listbox")).toHaveCSS("animation-name", "none");
|
||||
await page.getByRole("option").filter({ has: page.getByText("凤阳(新版)", { exact: true }) }).click();
|
||||
await dialog.getByRole("button", { name: "关闭项目切换" }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(page.getByRole("button", { name: "打开用户菜单" })).toBeFocused();
|
||||
const reopened = await openSwitcher(page);
|
||||
await expect(reopened.getByRole("combobox")).toContainText("奉贤演示项目");
|
||||
await expect(reopened.getByRole("combobox")).toBeFocused();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(reopened).toBeHidden();
|
||||
});
|
||||
}
|
||||
|
||||
test("ignores a remembered project that is absent from the authorized list", async ({ page }) => {
|
||||
await prepare(page);
|
||||
await page.addInitScript(() => localStorage.setItem("tjwater:active-project", "not-authorized"));
|
||||
const projectIds: string[] = [];
|
||||
page.on("request", (request) => {
|
||||
const project = request.headers()["x-project-id"];
|
||||
if (project) projectIds.push(project);
|
||||
});
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
const dialog = await openSwitcher(page);
|
||||
await expect(dialog.getByRole("combobox")).toContainText("奉贤演示项目");
|
||||
expect(projectIds).not.toContain("not-authorized");
|
||||
expect(projectIds).toContain(TEST_PROJECT_ID);
|
||||
});
|
||||
|
||||
test("opens the project list without a transient outer scrollbar", async ({ page }, testInfo) => {
|
||||
await prepare(page);
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
const dialog = await openSwitcher(page);
|
||||
await expect(dialog).toHaveCSS("opacity", "1");
|
||||
const sampling = page.evaluate(() => new Promise<{
|
||||
height: number; scrollHeight: number; overflow: string; viewportTransform: string;
|
||||
}[]>((resolve, reject) => {
|
||||
const frames: { height: number; scrollHeight: number; overflow: string; viewportTransform: string }[] = [];
|
||||
const timeout = setTimeout(() => { observer.disconnect(); reject(new Error("Project list did not open")); }, 10_000);
|
||||
const observer = new MutationObserver(() => {
|
||||
const content = document.querySelector<HTMLElement>('[role="listbox"]');
|
||||
if (!content) return;
|
||||
observer.disconnect();
|
||||
const start = performance.now();
|
||||
const sample = () => {
|
||||
const viewport = content.querySelector<HTMLElement>('[data-radix-select-viewport]');
|
||||
frames.push({ height: content.clientHeight, scrollHeight: content.scrollHeight,
|
||||
overflow: getComputedStyle(content).overflowY,
|
||||
viewportTransform: viewport ? getComputedStyle(viewport).transform : "missing" });
|
||||
if (performance.now() - start < 350) requestAnimationFrame(sample);
|
||||
else { clearTimeout(timeout); resolve(frames); }
|
||||
};
|
||||
sample();
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
}));
|
||||
await dialog.getByRole("combobox").click();
|
||||
const samples = await sampling;
|
||||
await testInfo.attach("first-open-scroll-frames", { body: JSON.stringify(samples, null, 2), contentType: "application/json" });
|
||||
expect(samples.length).toBeGreaterThan(1);
|
||||
expect(samples.filter((sample) => ["auto", "scroll"].includes(sample.overflow) && sample.scrollHeight > sample.height)).toEqual([]);
|
||||
await expect(page.getByRole("option")).toHaveCount(2);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog.getByRole("combobox")).toBeFocused();
|
||||
});
|
||||
|
||||
test("keeps long project lists scrollable with keyboard selection", async ({ page }) => {
|
||||
await prepare(page);
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||
const catalog = [...projects, ...Array.from({ length: 40 }, (_, index) => ({
|
||||
project_id: `project-${index}`, name: `测试供水项目 ${index + 1}`,
|
||||
code: `project_${index}`, gs_workspace: `project_${index}`, status: "active"
|
||||
}))];
|
||||
await page.route("https://tjwater-api.invalid/api/v1/projects?**", (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback();
|
||||
return route.fulfill({ headers: { "Access-Control-Allow-Origin": "*" },
|
||||
json: { items: catalog, total: catalog.length, limit: 1000, offset: 0 } });
|
||||
});
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
const dialog = await openSwitcher(page);
|
||||
await dialog.getByRole("combobox").click();
|
||||
await expect(page.getByRole("option")).toHaveCount(catalog.length);
|
||||
const viewport = page.locator("[data-radix-select-viewport]");
|
||||
await expect.poll(() => viewport.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true);
|
||||
await page.getByRole("option").first().press("End");
|
||||
await expect(page.getByRole("option").last()).toBeFocused();
|
||||
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBeGreaterThan(0);
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(dialog.getByRole("combobox")).toContainText("测试供水项目 40");
|
||||
});
|
||||
|
||||
for (const input of ["pointer", "keyboard"] as const) {
|
||||
test(`selects a project without a dark dialog focus outline using ${input}`, async ({ page }, testInfo) => {
|
||||
await prepare(page);
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
const dialog = await openSwitcher(page, input);
|
||||
await expect(dialog).toHaveCSS("opacity", "1");
|
||||
const trace = await dialog.evaluateHandle((element) => {
|
||||
const samples: { isDialog: boolean; tag: string; outline: string; width: number; color: string }[] = [];
|
||||
element.addEventListener("focusin", (event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
const style = getComputedStyle(element);
|
||||
samples.push({ isDialog: target === element, tag: target.tagName,
|
||||
outline: style.outlineStyle, width: parseFloat(style.outlineWidth), color: style.outlineColor });
|
||||
});
|
||||
return samples;
|
||||
});
|
||||
const trigger = dialog.getByRole("combobox");
|
||||
await expect(trigger).toBeFocused();
|
||||
const focusedBorder = await trigger.evaluate((element) => getComputedStyle(element).borderTopColor);
|
||||
if (input === "keyboard") {
|
||||
await trigger.press("ArrowDown");
|
||||
await page.getByRole("option").filter({ has: page.getByText("凤阳(新版)", { exact: true }) }).focus();
|
||||
await page.keyboard.press("Enter");
|
||||
} else await chooseProject(page, "凤阳(新版)");
|
||||
await expect(trigger).toContainText("凤阳(新版)");
|
||||
await expect(trigger).toBeFocused();
|
||||
await expect(trigger).toHaveCSS("border-top-color", focusedBorder);
|
||||
const samples = await trace.jsonValue();
|
||||
await trace.dispose();
|
||||
await testInfo.attach("project-option-focus-trace", { body: JSON.stringify(samples, null, 2), contentType: "application/json" });
|
||||
expect(samples.length).toBeGreaterThan(0);
|
||||
expect(samples.filter((sample) => sample.isDialog && sample.width > 0 && sample.outline !== "none")).toEqual([]);
|
||||
await expect(dialog.getByRole("button", { name: "进入项目", exact: true })).toBeEnabled();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
test("place search follows project configuration after switching", async ({ page }, testInfo) => {
|
||||
await prepare(page);
|
||||
await page.route("**/api/v1/place-search-requests", async (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback();
|
||||
expect(route.request().headers()["x-project-id"]).toBe(FENGYANG_ID);
|
||||
expect(route.request().postDataJSON()).toEqual({ keyword: "府城镇" });
|
||||
return route.fulfill({ headers: { "Access-Control-Allow-Origin": "*" }, json: {
|
||||
items: [{ id: "fengyang-place", name: "府城镇", address: "安徽省滁州市凤阳县府城镇", longitude: 117.56,
|
||||
latitude: 32.86, category: "行政区", district: "凤阳县" }], total: 1, truncated: false, region: "凤阳县"
|
||||
} });
|
||||
});
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
const dialog = await openSwitcher(page);
|
||||
await chooseProject(page, "凤阳(新版)");
|
||||
await dialog.getByRole("button", { name: "进入项目", exact: true }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
await page.getByRole("button", { name: "地址定位:查询地址并定位到地图" }).click();
|
||||
const panel = page.getByRole("region", { name: "地址定位", exact: true });
|
||||
await expect(panel).toContainText("凤阳县 · 天地图地名搜索");
|
||||
await expect(panel).not.toContainText("奉贤区");
|
||||
await panel.getByLabel("查询地址").fill("府城镇");
|
||||
await panel.getByRole("button", { name: "搜索地名" }).click();
|
||||
await panel.getByRole("button", { name: "定位 府城镇 安徽省滁州市凤阳县府城镇", exact: true }).click();
|
||||
await expect(page.locator('[data-geocoding-marker="true"]')).toHaveCount(1);
|
||||
await expect(panel.getByRole("region", { name: "定位结果" })).toContainText("117.560000, 32.860000");
|
||||
await panel.screenshot({ path: testInfo.outputPath("fengyang-place-search.png"), animations: "disabled" });
|
||||
});
|
||||
|
||||
test("unconfigured project disables place search", async ({ page }) => {
|
||||
await prepare(page, false, false);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "地址定位:查询地址并定位到地图" }).click();
|
||||
const panel = page.getByRole("region", { name: "地址定位", exact: true });
|
||||
await expect(panel).toContainText("当前项目暂未配置地名搜索");
|
||||
await expect(panel.getByRole("button", { name: "搜索地名" })).toBeDisabled();
|
||||
});
|
||||
@@ -1,5 +1,47 @@
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test";
|
||||
import { mockFlatWorkOrder } from "./support/mock-hierarchical-work-order";
|
||||
import { mockWorkbenchContext } from "./support/mock-workbench-context";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockWorkbenchContext(page);
|
||||
});
|
||||
|
||||
test("shows mock scheduled conditions outside the demo project", async ({ page }) => {
|
||||
const project = {
|
||||
project_id: "b962c44e-8433-4a10-81e9-a64b70ac1152",
|
||||
name: "凤阳(新版)",
|
||||
code: "fengyang_v2",
|
||||
gs_workspace: "fengyang_v2",
|
||||
status: "active"
|
||||
};
|
||||
await mockWorkbenchContext(page, undefined, {
|
||||
metadata: { ...project, map_config: {}, map_extent: null, project_role: "member" },
|
||||
accessContext: {
|
||||
user_id: "operator",
|
||||
username: "project.operator",
|
||||
system_role: "user",
|
||||
is_system_admin: false,
|
||||
project_id: project.project_id,
|
||||
project_role: "member",
|
||||
permissions: ["webgis.view"]
|
||||
}
|
||||
});
|
||||
await page.route("https://tjwater-api.invalid/api/v1/projects?**", (route) => route.fulfill({
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
json: { items: [project], total: 1, limit: 1000, offset: 0 }
|
||||
}));
|
||||
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await openConditionFeed(page);
|
||||
|
||||
const panel = page.getByRole("region", { name: "工况任务", exact: true });
|
||||
await panel.getByRole("button", { name: "展开工况任务" }).focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(panel.getByRole("button", { name: /定时任务/ })).toContainText("模拟数据");
|
||||
await expect(
|
||||
panel.getByTestId("scheduled-condition-timeline").getByRole("button", { name: /SCADA 数据诊断/ }).first()
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("keeps the open scheduled report when the rolling history refreshes", async ({ page }) => {
|
||||
await page.clock.install({ time: new Date("2026-09-24T09:20:53Z") });
|
||||
@@ -11,13 +53,17 @@ test("keeps the open scheduled report when the rolling history refreshes", async
|
||||
const rows = panel.getByTestId("scheduled-condition-timeline").locator("button[aria-pressed]");
|
||||
const oldestReport = rows.last();
|
||||
const reportTitle = await oldestReport.textContent();
|
||||
const reportId = await oldestReport.getAttribute("data-condition-id");
|
||||
expect(reportTitle).toContain("11:20SCADA 数据诊断");
|
||||
expect(reportId).not.toBeNull();
|
||||
await oldestReport.click();
|
||||
await expect(oldestReport).toHaveAttribute("aria-pressed", "true");
|
||||
|
||||
await page.clock.runFor(12_000);
|
||||
|
||||
await expect(rows.filter({ hasText: reportTitle ?? "" })).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(
|
||||
panel.locator(`[data-condition-id="${reportId}"]`)
|
||||
).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(panel.getByTestId("scheduled-condition-detail-scroll"))
|
||||
.toContainText("11:20 的SCADA 数据诊断");
|
||||
|
||||
@@ -116,9 +162,11 @@ test("narrow desktop keeps Agent and expanded conditions from overlapping", asyn
|
||||
24
|
||||
);
|
||||
|
||||
await panel.getByRole("button", { name: "展开工况任务" }).click();
|
||||
await panel.getByRole("button", { name: "展开工况任务" }).focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.locator('aside[aria-label="Agent 折叠栏"]')).toBeVisible();
|
||||
await expect(panel).toHaveCSS("width", "852px");
|
||||
await expect.poll(async () => (await panel.boundingBox())?.width ?? 0).toBeGreaterThan(800);
|
||||
await expect.poll(async () => (await panel.boundingBox())?.width ?? Infinity).toBeLessThanOrEqual(880);
|
||||
|
||||
const expandedConditionBox = await panel.boundingBox();
|
||||
const collapsedAgentBox = await page
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { mockAgentApi } from "./support/mock-agent-api";
|
||||
import { mockWorkbenchContext } from "./support/mock-workbench-context";
|
||||
import { TEST_PROJECT_ID } from "./support/mock-scada-api";
|
||||
|
||||
const project = { project_id: TEST_PROJECT_ID, name: "奉贤供水项目", code: "tjwater_v2", gs_workspace: "tjwater_next", status: "active" };
|
||||
const headers = { "Access-Control-Allow-Origin": "*" };
|
||||
const gate = () => {
|
||||
let release = () => {};
|
||||
const ready = new Promise<void>((resolve) => { release = resolve; });
|
||||
return { ready, release: () => release() };
|
||||
};
|
||||
|
||||
for (const width of [1440, 375, 320]) {
|
||||
test(`project startup follows real loading stages at ${width}px`, async ({ page }, testInfo) => {
|
||||
const projects = gate();
|
||||
const context = gate();
|
||||
await page.setViewportSize({ width, height: width === 1440 ? 900 : 740 });
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await mockWorkbenchContext(page);
|
||||
await mockAgentApi(page);
|
||||
await page.route("**/api/v1/projects?**", async (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback();
|
||||
await projects.ready;
|
||||
await route.fulfill({ headers, json: { items: [project], total: 1, limit: 1000, offset: 0 } });
|
||||
});
|
||||
for (const path of ["projects/current/metadata", "access-context"]) {
|
||||
await page.route(`**/api/v1/${path}`, async (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback();
|
||||
await context.ready;
|
||||
await route.fallback();
|
||||
});
|
||||
}
|
||||
try {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
const startup = page.getByRole("main", { name: "应用启动" });
|
||||
await expect(startup.getByRole("heading", { name: "正在加载授权项目" })).toBeVisible();
|
||||
const steps = startup.getByRole("list", { name: "项目加载进度" }).getByRole("listitem");
|
||||
await expect(steps.nth(0)).toHaveAttribute("aria-current", "step");
|
||||
await expect(steps.nth(1)).toContainText("等待项目列表返回");
|
||||
await expect(startup.locator('[data-activity="loading"]')).toHaveCount(1);
|
||||
await expect(startup.locator('[data-activity="loading"]')).toHaveCSS("animation-name", "none");
|
||||
const panel = await startup.locator('[data-slot="startup-panel"]').boundingBox();
|
||||
expect(panel!.x).toBeGreaterThanOrEqual(16);
|
||||
expect(panel!.x + panel!.width).toBeLessThanOrEqual(width - 16);
|
||||
await page.screenshot({ path: testInfo.outputPath(`projects-loading-${width}.png`), animations: "disabled" });
|
||||
projects.release();
|
||||
await expect(startup.getByRole("heading", { name: "正在准备项目环境" })).toBeVisible();
|
||||
await expect(steps.nth(0)).toContainText("已完成");
|
||||
await expect(steps.nth(1)).toHaveAttribute("aria-current", "step");
|
||||
await expect(steps.nth(1)).toContainText("奉贤供水项目");
|
||||
await expect(page.getByTestId("workbench-main-frame")).toHaveCount(0);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
||||
await page.screenshot({ path: testInfo.outputPath(`project-preparing-${width}.png`), animations: "disabled" });
|
||||
context.release();
|
||||
await expect(startup).toHaveCount(0);
|
||||
await expect(page.getByTestId("workbench-main-frame")).toBeVisible();
|
||||
} finally { projects.release(); context.release(); }
|
||||
});
|
||||
|
||||
test(`authentication startup and reconnect share the same surface at ${width}px`, async ({ page }, testInfo) => {
|
||||
const authentication = gate();
|
||||
let fail = true;
|
||||
await page.setViewportSize({ width, height: width === 1440 ? 900 : 740 });
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||
await mockWorkbenchContext(page);
|
||||
await mockAgentApi(page);
|
||||
await page.route("**/src/app/app.tsx*", async (route) => {
|
||||
await authentication.ready;
|
||||
if (fail) return route.abort("failed");
|
||||
return route.fallback();
|
||||
});
|
||||
try {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
const startup = page.getByRole("main", { name: "应用启动" });
|
||||
await expect(startup.getByRole("heading", { name: "正在连接统一身份认证" })).toBeVisible();
|
||||
await expect(startup.getByRole("list", { name: "身份认证进度" })).toContainText("检查登录状态");
|
||||
await expect(startup.locator('[data-activity="loading"]')).not.toHaveCSS("animation-name", "none");
|
||||
await page.screenshot({ path: testInfo.outputPath(`authentication-loading-${width}.png`), animations: "disabled" });
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await expect(startup.locator('[data-activity="loading"]')).toHaveCSS("animation-name", "none");
|
||||
authentication.release();
|
||||
await expect(startup.getByRole("heading", { name: "统一身份认证连接失败" })).toBeVisible();
|
||||
await expect(startup.getByRole("alert")).toContainText("请检查网络连接后重试");
|
||||
await expect(startup.locator('[data-activity="loading"]')).toHaveCount(0);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
||||
await page.screenshot({ path: testInfo.outputPath(`authentication-error-${width}.png`), animations: "disabled" });
|
||||
fail = false;
|
||||
await startup.getByRole("button", { name: "重新连接" }).focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByTestId("workbench-main-frame")).toBeVisible();
|
||||
} finally { authentication.release(); }
|
||||
});
|
||||
}
|
||||
|
||||
test("project loading failure can retry without retaining a loading animation", async ({ page }, testInfo) => {
|
||||
let fail = true;
|
||||
const retry = gate();
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await mockWorkbenchContext(page);
|
||||
await mockAgentApi(page);
|
||||
await page.route("**/api/v1/projects?**", async (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback();
|
||||
if (fail) return route.fulfill({ status: 503, headers, json: { detail: "项目服务暂时不可用,请稍后重试。" } });
|
||||
await retry.ready;
|
||||
return route.fulfill({ headers, json: { items: [project], total: 1, limit: 1000, offset: 0 } });
|
||||
});
|
||||
try {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
const startup = page.getByRole("main", { name: "应用启动" });
|
||||
await expect(startup.getByRole("heading", { name: "暂时无法进入工作台" })).toBeVisible();
|
||||
await expect(startup.getByRole("alert")).toContainText("项目服务暂时不可用");
|
||||
await expect(startup.locator('[data-activity="loading"]')).toHaveCount(0);
|
||||
await page.screenshot({ path: testInfo.outputPath("project-loading-error.png"), animations: "disabled" });
|
||||
fail = false;
|
||||
await startup.getByRole("button", { name: "重新加载" }).press("Enter");
|
||||
await expect(startup.getByRole("heading", { name: "正在加载授权项目" })).toBeVisible();
|
||||
await expect(startup.getByRole("alert")).toHaveCount(0);
|
||||
retry.release();
|
||||
await expect(page.getByTestId("workbench-main-frame")).toBeVisible();
|
||||
} finally { retry.release(); }
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
export const answerUiDemoBlock = {
|
||||
version: "answer-ui@1",
|
||||
block_id: "pressure",
|
||||
fallback_text: "演练压力读数,仍待现场核验。",
|
||||
spec: {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: {
|
||||
type: "Stack",
|
||||
props: { columns: "one", gap: "normal" },
|
||||
children: ["metrics", "chart", "evidence", "location", "form"]
|
||||
},
|
||||
metrics: {
|
||||
type: "Stack",
|
||||
props: { columns: "two", gap: "compact" },
|
||||
children: ["pressure", "flow"]
|
||||
},
|
||||
pressure: {
|
||||
type: "Metric",
|
||||
props: { label: "压力", value: "0.30", unit: "MPa", source: "演练数据" },
|
||||
children: []
|
||||
},
|
||||
flow: {
|
||||
type: "Metric",
|
||||
props: { label: "流量", value: "18", unit: "m³/h", source: "演练数据" },
|
||||
children: []
|
||||
},
|
||||
chart: {
|
||||
type: "Chart",
|
||||
props: {
|
||||
title: "演练监测趋势",
|
||||
chart_type: "line",
|
||||
x_data: ["08:00", "09:00"],
|
||||
series: [{ name: "压力(MPa)", data: [0.3, 0.28] }, { name: "参考压力(MPa)", data: [0.29, 0.26] }],
|
||||
source: "浏览器验收演练数据"
|
||||
},
|
||||
children: []
|
||||
},
|
||||
evidence: {
|
||||
type: "Evidence",
|
||||
props: {
|
||||
title: "查看演练证据",
|
||||
summary: "仅用于组件验收,不代表已核验现场事实。",
|
||||
source: "浏览器演练",
|
||||
evidence_id: "DEMO-001",
|
||||
observed_at: null,
|
||||
kind: "demo"
|
||||
},
|
||||
children: []
|
||||
},
|
||||
location: {
|
||||
type: "MapLocation",
|
||||
props: {
|
||||
label: "定位演练监测点",
|
||||
layer: "scada",
|
||||
feature_ids: ["DEMO-P001"],
|
||||
source: "演练监测点"
|
||||
},
|
||||
children: []
|
||||
},
|
||||
form: {
|
||||
type: "FollowUpForm",
|
||||
props: {
|
||||
title: "补充核验资料",
|
||||
description: "填写后由你主动发送给 Agent。",
|
||||
fields: [{ name: "location", label: "现场位置", kind: "text", required: true }]
|
||||
},
|
||||
children: []
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -34,6 +34,9 @@ export async function mockAgentApi(page: Page, options: MockAgentApiOptions = {}
|
||||
}
|
||||
|
||||
const pathname = new URL(route.request().url()).pathname;
|
||||
if (pathname.endsWith("/topics") && route.request().method() === "GET") {
|
||||
await route.fulfill({ headers: corsHeaders, json: { topics: [] } }); return;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/suggestions") && route.request().method() === "POST") {
|
||||
await route.fulfill({ headers: corsHeaders, json: {
|
||||
@@ -94,13 +97,14 @@ export async function mockAgentApi(page: Page, options: MockAgentApiOptions = {}
|
||||
const sessionMatch = pathname.match(/\/session\/([^/]+)$/);
|
||||
if (sessionMatch && route.request().method() === "GET") {
|
||||
const collaboration = sessionMatch[1].startsWith("collaboration-");
|
||||
const stored = options.sessions?.find((session) => (session.session_id ?? session.id) === sessionMatch[1]);
|
||||
await route.fulfill({
|
||||
headers: corsHeaders,
|
||||
json: {
|
||||
id: sessionMatch[1],
|
||||
session_id: sessionMatch[1],
|
||||
title: collaboration ? "任务协作问答" : "水质投诉事件调查",
|
||||
purpose: collaboration ? "task_collaboration" : "ordinary_chat",
|
||||
title: stored?.title ?? (collaboration ? "任务协作问答" : "水质投诉事件调查"),
|
||||
purpose: stored?.purpose ?? (collaboration ? "task_collaboration" : "ordinary_chat"),
|
||||
task_id: collaboration ? sessionMatch[1].slice("collaboration-".length) : undefined,
|
||||
status: "active",
|
||||
created_at: "2026-09-22T02:00:00Z",
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Locator, TestInfo } from "@playwright/test";
|
||||
|
||||
/** Measure the actual reading surface so density changes can be compared with screenshots. */
|
||||
export async function captureReadingGeometry(workspace: Locator, testInfo: TestInfo) {
|
||||
const geometry = await workspace.evaluate((element) => {
|
||||
const scroll = element.querySelector("[data-reading-scroll]")!;
|
||||
const metrics = element.querySelector(".answer-ui-stack-comparison")!;
|
||||
const chart = element.querySelector('.answer-ui-chart [role="img"]')!;
|
||||
const bounds = scroll.getBoundingClientRect();
|
||||
return {
|
||||
viewportWidth: window.innerWidth,
|
||||
readingWidth: Math.round(bounds.width),
|
||||
metricsHeight: Math.round(metrics.getBoundingClientRect().height),
|
||||
chartOffset: Math.round(chart.getBoundingClientRect().top - bounds.top + scroll.scrollTop),
|
||||
horizontalOverflow: document.documentElement.scrollWidth > window.innerWidth,
|
||||
};
|
||||
});
|
||||
await testInfo.attach("reading-geometry", { body: JSON.stringify(geometry, null, 2), contentType: "application/json" });
|
||||
return geometry;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// Translate existing UI scenarios into Responses wire fixtures. Production
|
||||
// clients accept Responses only; this helper belongs exclusively to tests.
|
||||
export function responseFixtureWriter() {
|
||||
export function responseFixtureWriter(conversationId?: string) {
|
||||
let sequence = 0;
|
||||
let id = "resp_browser_fixture";
|
||||
let messageId = "msg_browser_fixture";
|
||||
@@ -13,7 +13,7 @@ export function responseFixtureWriter() {
|
||||
return (chunk: Record<string, unknown>) => {
|
||||
if (chunk.type === "start") {
|
||||
messageId = String(chunk.messageId); id = `resp_${messageId}`;
|
||||
return event("response.created", { response: { id, object: "response", status: "in_progress" } })
|
||||
return event("response.created", { response: { id, object: "response", status: "in_progress", ...(conversationId ? { conversation: { id: conversationId } } : {}) } })
|
||||
+ event("response.output_item.added", { output_index: 0, item: { type: "message", role: "assistant", id: messageId, content: [] } });
|
||||
}
|
||||
if (chunk.type === "text-start") {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { mockAgentApi } from "./support/mock-agent-api";
|
||||
import { mockWorkbenchContext } from "./support/mock-workbench-context";
|
||||
import { createAnalysisRuntimeSnapshot, MOCK_SPATIAL_ANALYSIS } from "./support/mock-analysis-runtime";
|
||||
|
||||
test("nested Resizable groups keep chat, map and analysis independent across breakpoints", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1920, height: 1000 });
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await mockAgentApi(page);
|
||||
await mockWorkbenchContext(page, createAnalysisRuntimeSnapshot(MOCK_SPATIAL_ANALYSIS));
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
|
||||
const agent = page.getByRole("complementary", { name: "Agent 命令面板" });
|
||||
await agent.locator("textarea").fill("保留分栏调整中的分析问题");
|
||||
await page.getByRole("button", { name: "打开最新后端分析成果" }).click();
|
||||
const outer = page.getByRole("separator", { name: "调整 Agent 面板宽度" });
|
||||
const inner = page.getByRole("separator", { name: "调整地图与报告宽度" });
|
||||
const map = page.locator("#workspace-map");
|
||||
await expect(inner).toBeVisible();
|
||||
await page.waitForFunction(() => Boolean(window.__waterNetworkMap));
|
||||
await page.evaluate(() => Object.assign(window, { originalResizableMap: window.__waterNetworkMap }));
|
||||
|
||||
const outerBox = (await outer.boundingBox())!;
|
||||
await page.mouse.move(outerBox.x + outerBox.width / 2, outerBox.y + outerBox.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(620, outerBox.y + outerBox.height / 2, { steps: 6 });
|
||||
await page.mouse.up();
|
||||
await expect.poll(async () => Math.round((await agent.boundingBox())!.width)).toBe(620);
|
||||
const mapWidth = (await map.boundingBox())!.width;
|
||||
const innerBox = (await inner.boundingBox())!;
|
||||
await page.mouse.move(innerBox.x + innerBox.width / 2, innerBox.y + innerBox.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(innerBox.x + 40, innerBox.y + innerBox.height / 2, { steps: 6 });
|
||||
await page.mouse.up();
|
||||
await expect.poll(async () => (await map.boundingBox())!.width).toBeGreaterThan(mapWidth + 30);
|
||||
await expect.poll(async () => Math.round((await agent.boundingBox())!.width)).toBe(620);
|
||||
await inner.focus();
|
||||
await page.keyboard.press("Home");
|
||||
await expect.poll(async () => Math.round((await map.boundingBox())!.width)).toBe(600);
|
||||
await page.keyboard.press("End");
|
||||
await expect.poll(async () => Math.round((await page.locator("#artifact-workspace").boundingBox())!.width)).toBe(600);
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await page.screenshot({ path: "test-results/resizable-three-columns.png" });
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await expect(inner).toBeHidden();
|
||||
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "focus");
|
||||
await page.setViewportSize({ width: 1920, height: 1000 });
|
||||
await expect(inner).toBeVisible();
|
||||
await expect.poll(async () => Math.round((await agent.boundingBox())!.width)).toBe(620);
|
||||
await expect(agent.locator("textarea")).toHaveValue("保留分栏调整中的分析问题");
|
||||
expect(await page.evaluate(() => window.__waterNetworkMap === (window as unknown as { originalResizableMap: unknown }).originalResizableMap)).toBe(true);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(1920);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
@@ -107,13 +107,25 @@ test("spatial Artifacts switch between split, focus and peek", async ({ page })
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await openFirstArtifact(page);
|
||||
|
||||
const separator = page.getByRole("separator", { name: "调整地图与报告宽度" });
|
||||
const map = page.locator("#workspace-map");
|
||||
await expect(separator).toHaveAttribute("data-slot", "resizable-handle");
|
||||
const before = (await map.boundingBox())!.width;
|
||||
await separator.focus();
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await expect.poll(async () => (await map.boundingBox())!.width).toBeGreaterThan(before);
|
||||
const resized = (await map.boundingBox())!.width;
|
||||
await expect.poll(async () => (await page.locator("#artifact-workspace").boundingBox())!.width).toBeGreaterThanOrEqual(600);
|
||||
|
||||
await page.getByRole("button", { name: "专注分析" }).click();
|
||||
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "focus");
|
||||
await expect(page.locator("#workspace-map")).toBeHidden();
|
||||
await expect(separator).toBeHidden();
|
||||
|
||||
await page.getByRole("button", { name: "半屏显示" }).click();
|
||||
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "map_split");
|
||||
await expect(page.locator("#workspace-map")).toBeVisible();
|
||||
await expect.poll(async () => (await map.boundingBox())!.width).toBeCloseTo(resized, 0);
|
||||
|
||||
await page.getByRole("button", { name: "将成果收起为预览" }).click();
|
||||
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "default");
|
||||
|
||||
Reference in New Issue
Block a user