278 lines
16 KiB
TypeScript
278 lines
16 KiB
TypeScript
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||
import { describe, expect, it, vi } from "vitest";
|
||
import userEvent from "@testing-library/user-event";
|
||
import { AnswerUiView } from "./answer-ui-view";
|
||
import { parseAnswerUiBlock } from "./contract";
|
||
vi.mock("echarts-for-react", () => ({ default: () => <div aria-label="测试图形" /> }));
|
||
|
||
function block(type: string, props: Record<string, unknown>) {
|
||
const parsed = parseAnswerUiBlock({
|
||
version: "answer-ui@1",
|
||
block_id: "test",
|
||
fallback_text: "演练",
|
||
spec: { root: "node", elements: { node: { type, props, children: [] } } }
|
||
});
|
||
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("shows navigation for long answers while keeping short metric and evidence replies quiet", () => {
|
||
const fixture = taskBlock(false);
|
||
fixture.spec.elements.root.children = ["metric", "evidence"];
|
||
delete fixture.spec.elements.form;
|
||
const { rerender } = render(<AnswerUiView block={fixture} />);
|
||
expect(screen.getByRole("group", { name: "当前压力" })).toBeVisible();
|
||
expect(screen.queryByRole("navigation", { name: "回答内容导航" })).not.toBeInTheDocument();
|
||
const longer = structuredClone(fixture);
|
||
longer.spec.elements.evidence.props.summary = "待核对的监测资料。".repeat(150);
|
||
rerender(<AnswerUiView block={longer} />);
|
||
expect(screen.getByRole("navigation", { name: "回答内容导航" })).toBeVisible();
|
||
});
|
||
|
||
it("uses a single form and evidence card's own headings without fixed wrapper instructions", () => {
|
||
render(<AnswerUiView block={taskBlock()} onSubmit={vi.fn()} />);
|
||
expect(screen.getByLabelText("现场记录(必填)")).toBeVisible();
|
||
expect(screen.getByRole("button", { name: /读数依据/ })).toBeVisible();
|
||
expect(screen.queryByRole("button", { name: "收起补充资料" })).not.toBeInTheDocument();
|
||
expect(screen.queryByRole("button", { name: "收起核查依据" })).not.toBeInTheDocument();
|
||
expect(screen.queryByText("直接探索已加载数据")).not.toBeInTheDocument();
|
||
expect(screen.queryByText("区分记录、推断与用户资料")).not.toBeInTheDocument();
|
||
});
|
||
|
||
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("shows data and evidence without row selection, quoting or analysis controls", async () => {
|
||
const user = userEvent.setup();
|
||
const submit = vi.fn();
|
||
render(<AnswerUiView block={taskBlock()} onSubmit={submit} />);
|
||
expect(screen.getByRole("group", { name: "当前压力" })).toHaveTextContent("0.2130MPa");
|
||
expect(screen.getByRole("table")).toHaveTextContent("北区入口");
|
||
expect(screen.getAllByRole("columnheader")).toHaveLength(2);
|
||
await user.click(screen.getByRole("button", { name: /读数依据.*测试查询/ }));
|
||
await waitFor(() => expect(screen.getByText("仅用于演练")).toBeVisible());
|
||
expect(screen.getByText("DEMO-26")).toBeVisible();
|
||
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
|
||
expect(screen.queryByRole("button", { name: /加入分析|针对.*提问|取消选行/ })).not.toBeInTheDocument();
|
||
expect(screen.queryByRole("region", { name: "分析内容篮" })).not.toBeInTheDocument();
|
||
expect(submit).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("keeps the same form when more data arrives and support sections collapse", async () => {
|
||
const user = userEvent.setup();
|
||
const { rerender } = render(<AnswerUiView block={taskBlock(false)} onSubmit={vi.fn()} />);
|
||
const editor = screen.getByLabelText("现场记录(必填)");
|
||
await user.type(editor, "未发送的现场草稿");
|
||
const grouped = taskBlock();
|
||
grouped.spec.elements.root.children = [...(grouped.spec.elements.root.children ?? []), "extra-form"];
|
||
grouped.spec.elements["extra-form"] = { ...grouped.spec.elements.form, props: {
|
||
title: "其他资料", description: "可选补充", fields: [{ name: "extra", label: "其他记录", kind: "text", required: false }]
|
||
} };
|
||
rerender(<AnswerUiView block={grouped} 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("未发送的现场草稿");
|
||
await user.click(screen.getByRole("button", { name: "收起补充资料" }));
|
||
const updated = taskBlock();
|
||
updated.spec.elements.table.props.rows = [["新增入口", "0.24"]];
|
||
rerender(<AnswerUiView block={updated} onSubmit={vi.fn()} />);
|
||
expect(screen.getByLabelText("现场记录(必填)")).toBe(editor);
|
||
await waitFor(() => expect(editor).toBeVisible());
|
||
expect(editor).toHaveValue("未发送的现场草稿");
|
||
});
|
||
|
||
it("preserves long original cell values without requiring a selection or quote action", () => {
|
||
const fixture = taskBlock();
|
||
fixture.spec.elements.table.props.rows = Array.from({ length: 12 }, (_, index) => [String(index), "长".repeat(800)]);
|
||
render(<AnswerUiView block={fixture} />);
|
||
expect(screen.getAllByText("长".repeat(800))).toHaveLength(12);
|
||
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
|
||
expect(screen.queryByRole("button", { name: /加入分析|针对.*提问/ })).not.toBeInTheDocument();
|
||
});
|
||
|
||
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("retains a metric's exact value, unit and source without action buttons", () => {
|
||
render(<AnswerUiView block={block("Metric", {
|
||
label: "当前压力", value: "0.3000", unit: "MPa", source: "已加载记录",
|
||
})} />);
|
||
const metric = screen.getByRole("group", { name: "当前压力" });
|
||
expect(metric).toHaveTextContent("0.3000MPa");
|
||
expect(metric).toHaveTextContent("来源:已加载记录");
|
||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||
});
|
||
it("renders through json-render and lets readers inspect exact chart data with keyboard tabs", async () => {
|
||
const user = userEvent.setup();
|
||
render(
|
||
<AnswerUiView
|
||
block={block("Chart", {
|
||
title: "演练压力",
|
||
chart_type: "line",
|
||
source: "演练数据",
|
||
x_data: ["08:00", "09:00"],
|
||
series: [{ name: "压力", data: [0.3, 0.2] }]
|
||
})}
|
||
/>
|
||
);
|
||
await user.click(screen.getByRole("tab", { name: /^数据$/ }));
|
||
expect(screen.getByRole("table")).toHaveTextContent("0.3");
|
||
expect(screen.getByRole("table")).toHaveTextContent("09:00");
|
||
expect(screen.getByRole("tab", { name: /^数据$/ })).toHaveAttribute("aria-selected", "true");
|
||
await user.keyboard("{ArrowLeft}");
|
||
expect(screen.getByRole("tab", { name: /^柱状$/ })).toHaveFocus();
|
||
expect(screen.getByRole("tab", { name: /^柱状$/ })).toHaveAttribute("aria-selected", "true");
|
||
expect(screen.queryByRole("table")).not.toBeInTheDocument();
|
||
});
|
||
it("runs map location only after a user click and reports missing IDs", async () => {
|
||
const locate = vi
|
||
.fn()
|
||
.mockResolvedValue({ located_ids: ["DEMO-P001"], missing_ids: ["DEMO-P002"] });
|
||
render(
|
||
<AnswerUiView
|
||
block={block("MapLocation", {
|
||
label: "定位演练监测点",
|
||
layer: "scada",
|
||
feature_ids: ["DEMO-P001", "DEMO-P002"],
|
||
source: "演练数据"
|
||
})}
|
||
onLocate={locate}
|
||
/>
|
||
);
|
||
expect(locate).not.toHaveBeenCalled();
|
||
fireEvent.click(screen.getByRole("button", { name: "定位演练监测点" }));
|
||
await waitFor(() => expect(screen.getByRole("status")).toHaveTextContent("另有 1 个未找到"));
|
||
expect(locate).toHaveBeenCalledWith({
|
||
layer: "scada",
|
||
feature_ids: ["DEMO-P001", "DEMO-P002"]
|
||
});
|
||
});
|
||
it("filters and sorts loaded rows without selecting or sending content", async () => {
|
||
const user = userEvent.setup();
|
||
const submit = vi.fn();
|
||
render(<AnswerUiView block={block("Table", {
|
||
title: "监测记录", columns: ["监测点", "压力"],
|
||
rows: [["南站", "0.32"], ["北站", "0.21"], ["南站二号", "0.26"]]
|
||
})} onSubmit={submit} />);
|
||
await user.type(screen.getByRole("textbox", { name: "搜索监测记录" }), "南站");
|
||
expect(screen.getByText("2 / 3 行")).toBeInTheDocument();
|
||
await user.click(screen.getByRole("button", { name: "按压力排序" }));
|
||
const rows = screen.getAllByRole("row");
|
||
expect(rows[1]).toHaveTextContent("南站二号");
|
||
expect(rows[1]).toHaveTextContent("0.26");
|
||
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
|
||
expect(screen.queryByRole("button", { name: /加入分析|针对.*提问/ })).not.toBeInTheDocument();
|
||
expect(submit).not.toHaveBeenCalled();
|
||
});
|
||
it("keeps form values after a failed submission", async () => {
|
||
const user = userEvent.setup();
|
||
render(<AnswerUiView block={block("FollowUpForm", {
|
||
title: "现场事实", description: "用户填写",
|
||
fields: [{ name: "detail", label: "记录", kind: "text", required: true }]
|
||
})} onSubmit={async () => { throw new Error("暂未发送"); }} />);
|
||
await user.type(screen.getByLabelText("记录(必填)"), "北站现场记录");
|
||
await user.click(screen.getByRole("button", { name: "发送补充资料" }));
|
||
expect(await screen.findByRole("status")).toHaveTextContent("暂未发送");
|
||
expect(screen.getByLabelText("记录(必填)")).toHaveValue("北站现场记录");
|
||
});
|
||
it("starts blank, requires input and sends one user message without claiming evidence is saved", async () => {
|
||
let complete!: () => void;
|
||
const submit = vi.fn(
|
||
() =>
|
||
new Promise<void>((resolve) => {
|
||
complete = resolve;
|
||
})
|
||
);
|
||
render(
|
||
<AnswerUiView
|
||
block={block("FollowUpForm", {
|
||
title: "补充现场资料",
|
||
description: "由用户提供",
|
||
fields: [{ name: "location", label: "现场位置", kind: "text", required: true }]
|
||
})}
|
||
onSubmit={submit}
|
||
/>
|
||
);
|
||
expect(screen.getByLabelText("现场位置(必填)")).toHaveValue("");
|
||
fireEvent.submit(screen.getByRole("form", { name: "补充现场资料" }));
|
||
expect(submit).not.toHaveBeenCalled();
|
||
expect(screen.getByRole("status")).toHaveTextContent("请填写必填资料");
|
||
fireEvent.change(screen.getByLabelText("现场位置(必填)"), {
|
||
target: { value: "用户指定位置" }
|
||
});
|
||
fireEvent.submit(screen.getByRole("form", { name: "补充现场资料" }));
|
||
expect(submit).toHaveBeenCalledTimes(1);
|
||
expect(submit.mock.calls[0]).toEqual([expect.stringContaining("现场位置:用户指定位置")]);
|
||
expect(screen.getByRole("button", { name: "正在发送…" })).toBeDisabled();
|
||
complete();
|
||
await waitFor(() =>
|
||
expect(screen.getByRole("status")).toHaveTextContent("资料已发送,请查看 Agent 回复")
|
||
);
|
||
});
|
||
});
|