feat: polish intelligent answer interactions

This commit is contained in:
2026-10-29 18:39:50 +08:00
parent 9474788db6
commit 1ee21d1c7f
26 changed files with 1762 additions and 327 deletions
@@ -1,4 +1,4 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { AnswerUiView } from "./answer-ui-view";
@@ -15,7 +15,134 @@ function block(type: string, props: Record<string, unknown>) {
if (!parsed) throw new Error("invalid test fixture");
return parsed;
}
function taskBlock(includeData = true) {
const children = ["metric", ...(includeData ? ["table"] : []), "evidence", "form"];
const fixture = {
version: "answer-ui@1", block_id: "task", fallback_text: "演练分析",
spec: { root: "root", elements: {
root: { type: "Stack", props: { columns: "one", gap: "normal" }, children },
metric: { type: "Metric", props: { label: "当前压力", value: "0.2130", unit: "MPa", source: "监测快照" }, children: [] },
...(includeData ? { 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-26", observed_at: null }, children: [] },
form: { type: "FollowUpForm", props: { title: "核验资料", description: "主动补充", fields: [{ name: "record", label: "现场记录", kind: "text", required: true }] }, children: [] }
} }
};
const parsed = parseAnswerUiBlock(fixture);
if (!parsed) throw new Error("invalid task fixture");
return parsed;
}
describe("local interactive answer renderer", () => {
it("adapts response and report composition without replacing a reader's form draft", async () => {
const user = userEvent.setup();
const fixture = taskBlock();
fixture.spec.elements.root.props = { columns: "one", gap: "normal", intent: "response", density: "compact" };
const { rerender, container } = render(<AnswerUiView block={fixture} onSubmit={vi.fn()} />);
const editor = screen.getByLabelText("现场记录(必填)");
await user.type(editor, "保留的现场草稿");
expect(container.querySelector("[data-answer-task-layout]")).toBeNull();
const report = { ...fixture, spec: { ...fixture.spec, elements: { ...fixture.spec.elements,
root: { ...fixture.spec.elements.root, props: { columns: "one", gap: "normal", intent: "report", density: "comfortable" } }
} } };
rerender(<AnswerUiView block={report} onSubmit={vi.fn()} />);
expect(container.querySelector("[data-answer-task-layout]")).not.toBeNull();
expect(screen.getByLabelText("现场记录(必填)")).toBe(editor);
expect(editor).toHaveValue("保留的现场草稿");
});
it("renders evidence-bound result states without treating historical results as live alerts", () => {
const fixture = (status: string) => block("Result", { title: "管道定位结果", summary: "已定位 3 根管道;高亮尚未确认。", status, source: "地图定位回执" });
const { rerender } = render(<AnswerUiView block={fixture("partial")} />);
const result = screen.getByRole("group", { name: "管道定位结果" });
expect(result).toHaveTextContent("部分完成");
expect(result).toHaveTextContent("依据:地图定位回执");
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(screen.queryByRole("button")).not.toBeInTheDocument();
for (const [status, label] of [["success", "成功"], ["unconfirmed", "未确认"], ["error", "失败"]]) {
rerender(<AnswerUiView block={fixture(status)} />);
expect(result).toHaveTextContent(label);
expect(result).toHaveAttribute("data-result-status", status);
}
});
it("uses the supplied note title without changing the warning meaning", () => {
render(<AnswerUiView block={block("Text", { text: "当前工具未提供高亮操作。", tone: "warning", title: "能力限制" })} />);
expect(screen.getByRole("note")).toHaveTextContent("能力限制");
expect(screen.getByRole("note")).not.toHaveTextContent("注意事项");
});
it("combines metrics, selected rows and evidence into one deliberate quote without sending", async () => {
const user = userEvent.setup();
const ask = vi.fn();
const submit = vi.fn();
render(<AnswerUiView block={taskBlock()} onAsk={ask} onSubmit={submit} />);
await user.click(screen.getByRole("button", { name: "将当前压力加入分析" }));
await user.click(screen.getByRole("checkbox", { name: "选择第 1 行" }));
await user.click(screen.getByRole("button", { name: "将所选行加入分析" }));
await user.click(screen.getByRole("button", { name: /读数依据.*测试查询/ }));
await user.click(screen.getByRole("button", { name: "将读数依据加入分析" }));
const tray = screen.getByRole("region", { name: "分析内容篮" });
expect(tray).toHaveTextContent("已选 3 项分析内容");
expect(ask).not.toHaveBeenCalled();
await user.type(within(tray).getByRole("textbox"), "比较差异并核查原因");
await user.click(within(tray).getByRole("button", { name: "带入对话" }));
expect(ask).toHaveBeenCalledWith({ title: "3 项内容的联合分析", text: expect.stringContaining("分析目标:比较差异并核查原因") });
const quote = ask.mock.calls[0][0].text;
for (const exact of ["0.2130MPa", "来源:监测快照", "对象:北区入口;压力(MPa):0.2130", "资料编号:DEMO-26", "引用内容块:task"]) expect(quote).toContain(exact);
expect(submit).not.toHaveBeenCalled();
await user.click(within(tray).getByRole("button", { name: "移除当前压力" }));
expect(tray).toHaveTextContent("已选 2 项分析内容");
expect(screen.getByRole("button", { name: "将当前压力加入分析" })).toHaveAttribute("aria-pressed", "false");
});
it("keeps the same form and selected content when more data arrives and support sections collapse", async () => {
const user = userEvent.setup();
const ask = vi.fn();
const { rerender } = render(<AnswerUiView block={taskBlock(false)} onAsk={ask} onSubmit={vi.fn()} />);
const editor = screen.getByLabelText("现场记录(必填)");
await user.type(editor, "未发送的现场草稿");
await user.click(screen.getByRole("button", { name: "将当前压力加入分析" }));
rerender(<AnswerUiView block={taskBlock()} onAsk={ask} onSubmit={vi.fn()} />);
expect(screen.getByLabelText("现场记录(必填)")).toBe(editor);
expect(editor).toHaveValue("未发送的现场草稿");
await user.click(screen.getByRole("button", { name: "收起补充资料" }));
expect(editor.closest("[data-answer-reveal]")).toHaveAttribute("inert");
await waitFor(() => expect(editor).not.toBeVisible());
await user.click(screen.getByRole("button", { name: "展开补充资料" }));
await waitFor(() => expect(editor).toBeVisible());
expect(editor).toHaveValue("未发送的现场草稿");
expect(screen.getByRole("region", { name: "分析内容篮" })).toHaveTextContent("已选 1 项分析内容");
const updated = taskBlock();
updated.spec.elements.table.props.rows = [["新增入口", "0.24"]];
rerender(<AnswerUiView block={updated} onAsk={ask} onSubmit={vi.fn()} />);
expect(screen.getByLabelText("现场记录(必填)")).toBe(editor);
expect(editor).toHaveValue("未发送的现场草稿");
});
it("rejects oversized row quotes without truncating values or removing existing selections", async () => {
const user = userEvent.setup();
const fixture = taskBlock();
fixture.spec.elements.table.props.rows = Array.from({ length: 12 }, (_, index) => [String(index), "长".repeat(800)]);
render(<AnswerUiView block={fixture} onAsk={vi.fn()} />);
await user.click(screen.getByRole("button", { name: "将当前压力加入分析" }));
await user.click(screen.getByRole("checkbox", { name: "选择入口记录的全部筛选结果" }));
await user.click(screen.getByRole("button", { name: "将所选行加入分析" }));
expect(screen.getByRole("status")).toHaveTextContent("8000 字");
expect(screen.getByRole("region", { name: "分析内容篮" })).toHaveTextContent("已选 1 项分析内容");
expect(screen.getByRole("checkbox", { name: "选择第 12 行" })).toBeChecked();
});
it("expresses supplied context and cautions as readable notes without announcing historical alerts", () => {
const { rerender } = render(<AnswerUiView block={block("Text", {
text: "压力记录尚未经过现场核验。", tone: "warning",
})} />);
expect(screen.getByRole("note")).toHaveTextContent("注意事项");
expect(screen.getByRole("note")).toHaveTextContent("压力记录尚未经过现场核验。");
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(screen.queryByRole("navigation")).not.toBeInTheDocument();
rerender(<AnswerUiView block={block("Text", { text: "仅展示已加载的记录。", tone: "info" })} />);
expect(screen.getByRole("note")).toHaveTextContent("补充说明");
rerender(<AnswerUiView block={block("Text", { text: "普通回答", tone: "neutral" })} />);
expect(screen.queryByRole("note")).not.toBeInTheDocument();
expect(screen.getByText("普通回答")).toBeInTheDocument();
});
it("offers a named metric quote action and retains the exact value, unit and source", async () => {
const user = userEvent.setup();
const ask = vi.fn();