feat: polish intelligent answer interactions
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
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";
|
||||
|
||||
const block = {
|
||||
version: "answer-ui@1", block_id: "components-motion", fallback_text: "组件交互演练,不代表现场事实。",
|
||||
spec: { root: "root", elements: {
|
||||
root: { type: "Stack", props: { columns: "one", gap: "normal", intent: "report" }, children: ["result", "note", "metric", "table", "evidence", "form", "location"] },
|
||||
result: { type: "Result", props: { title: "演练结果", summary: "待现场核验。", status: "unconfirmed", source: "浏览器演练" }, children: [] },
|
||||
note: { type: "Text", props: { title: "数据边界", tone: "info", text: "仅供交互验收。" }, children: [] },
|
||||
metric: { type: "Metric", props: { label: "演练入口压力", value: "0.2130", unit: "MPa", source: "浏览器演练" }, children: [] },
|
||||
table: { type: "Table", props: { title: "演练记录", columns: ["对象", "压力(MPa)"], rows: [["入口一", "0.2130"], ["入口二", "0.2870"]] }, children: [] },
|
||||
evidence: { type: "Evidence", props: { title: "演练依据", summary: "尚未核验现场事实。", source: "浏览器演练", kind: "demo", evidence_id: "DEMO-MOTION", observed_at: null }, children: [] },
|
||||
form: { type: "FollowUpForm", props: { title: "现场核验", description: "由你补充后带入对话。", fields: [{ name: "record", label: "现场记录", kind: "text", required: true }] }, children: [] },
|
||||
location: { type: "MapLocation", props: { label: "定位演练入口", layer: "junctions", feature_ids: ["DEMO-ENTRY"], source: "浏览器演练" }, children: [] },
|
||||
} },
|
||||
};
|
||||
|
||||
async function sampleToggle(button: Locator, selector: string) {
|
||||
return button.evaluate(async (button, selector) => {
|
||||
const reveal = button.closest("[data-answer-ui]")!.querySelector<HTMLElement>(selector)!;
|
||||
button.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
|
||||
button.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 1 }));
|
||||
const frames = [];
|
||||
for (let index = 0; index < 18; index++) {
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
frames.push({ opacity: Number(getComputedStyle(reveal).opacity), hidden: reveal.hidden,
|
||||
inert: reveal.inert, ariaHidden: reveal.getAttribute("aria-hidden") });
|
||||
}
|
||||
return frames;
|
||||
}, selector);
|
||||
}
|
||||
|
||||
for (const width of [1440, 320]) for (const reduce of [false, true]) {
|
||||
test(`answer component disclosures ${reduce ? "reduced" : "normal"} motion at ${width}px`, async ({ page }, testInfo) => {
|
||||
test.setTimeout(60000);
|
||||
await page.setViewportSize({ width, height: 1000 });
|
||||
await page.emulateMedia({ reducedMotion: reduce ? "reduce" : "no-preference" });
|
||||
await mockWorkbenchContext(page, undefined, undefined, {}, [], { runtimeMode: "backend" });
|
||||
await mockAgentApi(page);
|
||||
await page.route("**/api/v1/responses", async (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback();
|
||||
await route.fulfill({ headers: { "Access-Control-Allow-Origin": "*", "Content-Type": "text/event-stream" }, body: responsesFixture([
|
||||
{ type: "start", messageId: "components-answer" },
|
||||
{ type: "data-answer_ui", id: block.block_id, data: block },
|
||||
{ type: "data-final_answer", data: { content: "组件演练,待现场核验。", ui_block_ids: [block.block_id] } },
|
||||
{ type: "finish", finishReason: "stop" },
|
||||
]) });
|
||||
});
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
|
||||
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
|
||||
await panel.locator(".agent-panel-prompt-textarea").fill("检查演练组件");
|
||||
await panel.getByRole("button", { name: "发送 Agent 指令" }).click();
|
||||
await expect(panel.locator('[data-answer-ui="components-motion"]')).toBeVisible();
|
||||
await panel.getByRole("button", { name: "展开查看", exact: true }).click();
|
||||
const workspace = page.getByRole("region", { name: "展开报告工作区", exact: true });
|
||||
const ui = workspace.locator('[data-answer-ui="components-motion"]');
|
||||
const evidence = ui.locator(".answer-ui-evidence");
|
||||
const opened = await sampleToggle(evidence.getByRole("button").first(), ".answer-ui-evidence [data-answer-reveal]");
|
||||
expect(opened.some((frame) => frame.opacity > 0 && frame.opacity < 1)).toBe(!reduce);
|
||||
await expect(evidence.getByText("尚未核验现场事实。", { exact: true })).toBeVisible();
|
||||
const closed = await sampleToggle(evidence.getByRole("button").first(), ".answer-ui-evidence [data-answer-reveal]");
|
||||
expect(closed[0].inert).toBe(true);
|
||||
expect(closed[0].ariaHidden).toBe("true");
|
||||
expect(closed.at(-1)!.hidden).toBe(true);
|
||||
expect(closed.some((frame) => frame.opacity > 0 && frame.opacity < 1)).toBe(!reduce);
|
||||
// Keyboard actions and restoration never replay the pointer transition.
|
||||
await evidence.getByRole("button").first().focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(evidence.locator("[data-answer-reveal]")).toHaveCSS("opacity", "1");
|
||||
const editor = ui.getByLabel("现场记录(必填)");
|
||||
await editor.fill("保留同一个编辑器的现场草稿");
|
||||
await editor.evaluate((element) => element.setAttribute("data-original-editor", "yes"));
|
||||
await ui.getByRole("button", { name: "收起补充资料", exact: true }).click();
|
||||
await expect(editor).toBeHidden();
|
||||
await ui.getByRole("button", { name: "展开补充资料", exact: true }).click();
|
||||
await expect(editor).toHaveValue("保留同一个编辑器的现场草稿");
|
||||
await expect(editor).toHaveAttribute("data-original-editor", "yes");
|
||||
await expect(ui.getByText("需要你补充", { exact: true })).toHaveCount(0);
|
||||
const location = ui.locator(".answer-ui-location");
|
||||
await location.getByRole("button", { name: "查看关联要素", exact: true }).click();
|
||||
await expect(location.getByText("DEMO-ENTRY", { exact: true })).toBeVisible();
|
||||
await ui.getByRole("checkbox", { name: "选择第 1 行", exact: true }).check();
|
||||
await ui.getByRole("button", { name: "将所选行加入分析", exact: true }).click();
|
||||
const tray = ui.getByRole("region", { name: "分析内容篮" });
|
||||
await expect(tray).toContainText("已选 1 项分析内容");
|
||||
await tray.getByRole("button", { name: "演练记录", exact: true }).click();
|
||||
await expect(tray.getByText(/对象:入口一;压力(MPa):0.2130/)).toBeVisible();
|
||||
await tray.getByRole("textbox").fill("核查演练资料");
|
||||
await ui.getByRole("button", { name: "将演练入口压力加入分析", exact: true }).click();
|
||||
await tray.getByRole("button", { name: "移除演练入口压力", exact: true }).click();
|
||||
await expect(tray.getByRole("textbox")).toHaveValue("核查演练资料");
|
||||
await expect(editor).toHaveValue("保留同一个编辑器的现场草稿");
|
||||
await ui.getByRole("textbox", { name: "搜索演练记录" }).fill("不存在的入口");
|
||||
await expect(ui.getByRole("cell", { name: "当前筛选没有匹配的数据。" })).toBeVisible();
|
||||
await expect(ui.getByText("勾选数据行可加入分析或继续提问。", { exact: true })).toHaveCount(0);
|
||||
await ui.getByRole("textbox", { name: "搜索演练记录" }).fill("");
|
||||
await ui.getByRole("button", { name: "按压力(MPa)排序" }).click();
|
||||
await expect(ui.getByRole("columnheader", { name: "压力(MPa)" })).toHaveAttribute("aria-sort", "ascending");
|
||||
expect(await workspace.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(false);
|
||||
await ui.getByRole("button", { name: "收起补充资料", exact: true }).click();
|
||||
await ui.getByRole("button", { name: "展开补充资料", exact: true }).click();
|
||||
// A reverse action while the exit is running must not hide a reopened editor.
|
||||
await expect(editor).toBeVisible();
|
||||
await expect(editor).toHaveValue("保留同一个编辑器的现场草稿");
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await ui.locator(".answer-ui-form").screenshot({ path: testInfo.outputPath(`form-${width}-${reduce}.png`) });
|
||||
await evidence.screenshot({ path: testInfo.outputPath(`evidence-${width}-${reduce}.png`) });
|
||||
await tray.screenshot({ path: testInfo.outputPath(`basket-${width}-${reduce}.png`) });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
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";
|
||||
|
||||
// Synthetic readings exercise the same chart contract as the production answer.
|
||||
const chart = {
|
||||
version: "answer-ui@1", block_id: "chart-motion", fallback_text: "演练压力对比",
|
||||
spec: { root: "chart", elements: { chart: { type: "Chart", children: [], props: {
|
||||
title: "节点17038压力:事件日前后同时段对比(m)", chart_type: "line",
|
||||
x_data: ["07:00", "07:15", "07:30", "07:45", "08:00", "08:15", "08:30", "08:45", "09:00"],
|
||||
series: [{ name: "6月1日(m)", data: [57.8, 63, 64, 66, 67, 68, 71, 73.8, 73.93] },
|
||||
{ name: "6月2日(m)", data: [58.91, 62, 63, 65, 65, 66, 69, 71, 71.34] }],
|
||||
source: "浏览器演练数据,待现场核验",
|
||||
} } } },
|
||||
};
|
||||
|
||||
for (const width of [1440, 375]) for (const reduce of [false, true]) {
|
||||
test(`chart switches with ${reduce ? "reduced" : "normal"} motion at ${width}px`, async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width, height: 1000 });
|
||||
await page.emulateMedia({ reducedMotion: reduce ? "reduce" : "no-preference" });
|
||||
await mockWorkbenchContext(page, undefined, undefined, {}, [], { runtimeMode: "backend" });
|
||||
await mockAgentApi(page);
|
||||
await page.route("**/api/v1/responses", async (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback();
|
||||
await route.fulfill({ headers: { "Access-Control-Allow-Origin": "*", "Content-Type": "text/event-stream" },
|
||||
body: responsesFixture([
|
||||
{ type: "start", messageId: "chart-answer" },
|
||||
{ type: "data-answer_ui", id: chart.block_id, data: chart },
|
||||
{ type: "data-final_answer", data: { content: "演练资料,待现场核验。", ui_block_ids: [chart.block_id] } },
|
||||
{ type: "finish", finishReason: "stop" },
|
||||
]) });
|
||||
});
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
|
||||
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
|
||||
await panel.locator("textarea").fill("查看演练压力对比");
|
||||
await panel.getByRole("button", { name: "发送 Agent 指令" }).click();
|
||||
const ui = panel.locator(".answer-ui-chart");
|
||||
await expect(ui).toBeVisible();
|
||||
await expect(ui.getByRole("button", { name: "查看数据点", exact: true })).toHaveCount(0);
|
||||
await expect(ui.getByRole("region", { name: "数据点详情" })).toHaveCount(0);
|
||||
const content = ui.locator("[data-chart-view]");
|
||||
const samples = await ui.getByRole("tab", { name: "柱状", exact: true }).evaluate(async (tab) => {
|
||||
const content = tab.closest(".answer-ui-chart")!.querySelector("[data-chart-view]")!;
|
||||
const values: number[] = [];
|
||||
// Dispatch a real pointer modality before click, sampling the actual render over frames.
|
||||
tab.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
|
||||
tab.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0 }));
|
||||
(tab as HTMLElement).click();
|
||||
for (let i = 0; i < 16; i++) {
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
values.push(Number(getComputedStyle(content).opacity));
|
||||
}
|
||||
return values;
|
||||
});
|
||||
await expect(content).toHaveAttribute("data-chart-view", "bar");
|
||||
if (reduce) expect(samples.every((value) => value === 1)).toBe(true);
|
||||
else expect(samples.some((value) => value > 0 && value < 1)).toBe(true);
|
||||
await expect(content).toHaveCSS("opacity", "1");
|
||||
await ui.getByRole("tab", { name: "数据", exact: true }).click();
|
||||
await expect(ui.getByRole("group", { name: "当前区间摘要" })).toHaveCount(0);
|
||||
await ui.getByRole("textbox", { name: /搜索节点17038/ }).fill("08:30");
|
||||
await ui.getByRole("checkbox", { name: "选择第 7 行", exact: true }).check();
|
||||
const line = ui.getByRole("tab", { name: "折线", exact: true });
|
||||
await line.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(content).toHaveCSS("opacity", "1");
|
||||
// Exercise the real SVG hit target as well as the keyboard alternative.
|
||||
await ui.locator('svg path[stroke="#2563eb"]').last().click();
|
||||
await expect(ui.getByRole("region", { name: "数据点详情" })).toContainText("73.93");
|
||||
await ui.getByRole("button", { name: "关闭数据点详情" }).click();
|
||||
const plot = ui.getByRole("group", { name: /按回车查看数据点/ });
|
||||
await plot.focus();
|
||||
await page.keyboard.press("End");
|
||||
await expect(ui.getByRole("region", { name: "数据点详情" })).toContainText("73.93");
|
||||
await ui.getByRole("button", { name: "关闭数据点详情" }).click();
|
||||
await expect(plot).toBeFocused();
|
||||
await ui.getByRole("tab", { name: "数据", exact: true }).click();
|
||||
await expect(ui.getByRole("textbox", { name: /搜索节点17038/ })).toHaveValue("08:30");
|
||||
await expect(ui.getByRole("checkbox", { name: "选择第 7 行", exact: true })).toBeChecked();
|
||||
await line.click();
|
||||
await expect(content).toHaveCSS("opacity", "1");
|
||||
expect(await ui.evaluate((el) => el.scrollWidth > el.clientWidth)).toBe(false);
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await ui.screenshot({ path: testInfo.outputPath(`chart-${width}-${reduce}.png`) });
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user