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
@@ -0,0 +1,70 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { AnswerUiView } from "./answer-ui-view";
import { parseAnswerUiBlock } from "./contract";
vi.mock("echarts-for-react", () => ({ default: () => <div /> }));
function block(type: string, props: Record<string, unknown>) {
const result = parseAnswerUiBlock({ version: "answer-ui@1", block_id: "actions", fallback_text: "资料", spec: { root: "action", elements: { action: { type, props, children: [] } } } });
if (!result) throw new Error("invalid fixture");
return result;
}
const location = block("MapLocation", { label: "定位要素", source: "查询结果", layer: "pipes", feature_ids: ["P1", "P2"] });
const form = block("FollowUpForm", { title: "现场资料", description: "用户补充", fields: [{ name: "record", label: "记录", kind: "text", required: true }] });
describe("answer action states", () => {
it.each([
[{ located_ids: ["P1", "P2"], missing_ids: [] }, "success", "已定位 2 个"],
[{ located_ids: ["P1"], missing_ids: ["P2"] }, "partial", "另有 1 个未找到"],
[{ located_ids: [], missing_ids: ["P1", "P2"] }, "error", "未找到可定位"],
[undefined, "unconfirmed", "未返回定位结果"]
])("reports verified location outcomes %j", async (result, state, text) => {
render(<AnswerUiView block={location} onLocate={vi.fn().mockResolvedValue(result)} />);
fireEvent.click(screen.getByRole("button", { name: "定位要素" }));
await waitFor(() => expect(screen.getByRole("group", { name: "定位要素" })).toHaveAttribute("data-state", state));
expect(screen.getByRole("status")).toHaveTextContent(text);
});
it("explains unsupported location", () => {
render(<AnswerUiView block={location} />);
expect(screen.getByRole("button", { name: "定位要素" })).toBeDisabled();
expect(screen.getByText("当前视图不支持地图定位,可查看关联要素编号。")).toBeInTheDocument();
});
it("locks pending location and supports retry after rejection", async () => {
let reject!: (error: Error) => void;
const locate = vi.fn().mockImplementationOnce(() => new Promise((_, fail) => { reject = fail; })).mockResolvedValue({ located_ids: ["P1"], missing_ids: ["P2"] });
render(<AnswerUiView block={location} onLocate={locate} />);
fireEvent.click(screen.getByRole("button", { name: "定位要素" }));
expect(screen.getByRole("group", { name: "定位要素" })).toHaveAttribute("aria-busy", "true");
expect(screen.getByRole("button", { name: "正在定位…" })).toBeDisabled();
await act(async () => reject(new Error("网络失败")));
fireEvent.click(screen.getByRole("button", { name: "重试定位要素" }));
await waitFor(() => expect(locate).toHaveBeenCalledTimes(2));
});
it("associates required errors, retains failed drafts and clears success when editing", async () => {
let complete!: () => void;
const submit = vi.fn().mockRejectedValueOnce(new Error("发送失败")).mockImplementationOnce(() => new Promise<void>((resolve) => { complete = resolve; }));
render(<AnswerUiView block={form} onSubmit={submit} />);
const editor = screen.getByLabelText("记录(必填)");
const formElement = screen.getByRole("form", { name: "现场资料" });
fireEvent.submit(formElement);
expect(editor).toHaveAttribute("aria-invalid", "true");
expect(editor).toHaveAccessibleDescription("请填写记录。");
fireEvent.change(editor, { target: { value: "草稿" } });
fireEvent.submit(formElement);
await waitFor(() => expect(screen.getByRole("status")).toHaveTextContent("发送失败"));
expect(editor).toHaveValue("草稿");
fireEvent.submit(formElement);
fireEvent.submit(formElement);
expect(submit).toHaveBeenCalledTimes(2);
expect(formElement).toHaveAttribute("aria-busy", "true");
await act(async () => complete());
expect(screen.getByRole("status")).toHaveTextContent("资料已发送");
fireEvent.change(editor, { target: { value: "更新后的草稿" } });
expect(screen.queryByRole("status")).not.toBeInTheDocument();
expect(screen.getByRole("group", { name: "现场资料" })).toHaveAttribute("data-state", "idle");
});
it("confirms draft transfer without claiming submission", async () => {
render(<AnswerUiView block={form} submitMode="draft" onSubmit={vi.fn()} />);
fireEvent.change(screen.getByLabelText("记录(必填)"), { target: { value: "草稿" } });
fireEvent.click(screen.getByRole("button", { name: "带入对话" }));
await waitFor(() => expect(screen.getByRole("status")).toHaveTextContent("资料已带入对话,请确认后发送"));
});
});