106 lines
7.5 KiB
TypeScript
106 lines
7.5 KiB
TypeScript
import { fireEvent, render, screen } from "@testing-library/react";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { AnswerUiView } from "./answer-ui-view";
|
|
import { parseAnswerUiBlock } from "./contract";
|
|
import { normalizeAnswerViewState, type AnswerViewState } from "./answer-view-state";
|
|
|
|
vi.mock("echarts-for-react", () => ({ default: () => <div aria-label="测试图形" /> }));
|
|
function block(type: string, props: Record<string, unknown>, id = "node") {
|
|
const value = parseAnswerUiBlock({ version: "answer-ui@1", block_id: "state", fallback_text: "演练", spec: { root: id, elements: { [id]: { type, props, children: [] } } } });
|
|
if (!value) throw new Error("Invalid fixture");
|
|
return value;
|
|
}
|
|
const chart = (labels = ["一", "二"]) => block("Chart", { title: "压力", chart_type: "line", x_data: labels, series: [{ name: "入口", data: labels.map((_, index) => index) }], source: "测试" });
|
|
const form = (kind = "text") => block("FollowUpForm", { title: "补充", description: "主动填写", fields: [{ name: "record", kind, label: "现场记录", required: false }, { name: "other", kind: "text", label: "其他资料", required: false }] });
|
|
|
|
describe("controlled native answer state", () => {
|
|
it("keeps independent drafts for identically shaped nodes and never changes model props", () => {
|
|
const current = form();
|
|
const node = current.spec.elements.node;
|
|
current.spec = { root: "root", elements: { root: { type: "Stack", props: { columns: "one", gap: "normal" }, children: ["first", "second"] }, first: structuredClone(node), second: structuredClone(node) } };
|
|
const changed = vi.fn();
|
|
const original = JSON.stringify(current.spec);
|
|
render(<AnswerUiView block={current} onViewStateChange={changed} onSubmit={vi.fn()} />);
|
|
const inputs = screen.getAllByLabelText("现场记录");
|
|
fireEvent.change(inputs[0], { target: { value: "第一份草稿" } });
|
|
fireEvent.change(inputs[1], { target: { value: "第二份草稿" } });
|
|
expect(inputs[0]).toHaveValue("第一份草稿");
|
|
expect(inputs[1]).toHaveValue("第二份草稿");
|
|
expect(changed).toHaveBeenLastCalledWith(expect.objectContaining({ nodes: { first: { type: "FollowUpForm", values: { record: "第一份草稿" } }, second: { type: "FollowUpForm", values: { record: "第二份草稿" } } } }), ["/nodes/second/values/record"]);
|
|
expect(JSON.stringify(current.spec)).toBe(original);
|
|
});
|
|
|
|
it("records only the edited form field and restores it after remount", () => {
|
|
const current = form();
|
|
let saved: AnswerViewState = normalizeAnswerViewState(current.spec);
|
|
const changed = vi.fn((next: AnswerViewState) => { saved = next; });
|
|
const rendered = render(<AnswerUiView block={current} viewState={saved} onViewStateChange={changed} onSubmit={vi.fn()} />);
|
|
fireEvent.change(screen.getByLabelText("现场记录"), { target: { value: "尚未提交的草稿" } });
|
|
expect(changed).toHaveBeenLastCalledWith(expect.objectContaining({ nodes: { node: { type: "FollowUpForm", values: { record: "尚未提交的草稿" } } } }), ["/nodes/node/values/record"]);
|
|
rendered.unmount();
|
|
render(<AnswerUiView block={current} viewState={saved} onSubmit={vi.fn()} />);
|
|
expect(screen.getByLabelText("现场记录")).toHaveValue("尚未提交的草稿");
|
|
expect(screen.getByLabelText("其他资料")).toHaveValue("");
|
|
});
|
|
|
|
it("keeps chart data-tab search across data refresh without remounting", () => {
|
|
const current = chart();
|
|
const state = normalizeAnswerViewState(current.spec);
|
|
if (state.nodes.node.type === "Chart") state.nodes.node.view = "table";
|
|
const { rerender } = render(<AnswerUiView block={current} viewState={state} />);
|
|
const search = screen.getByRole("textbox", { name: "搜索压力" });
|
|
fireEvent.change(search, { target: { value: "一" } });
|
|
// A local owner can omit the external snapshot while keeping the same block instance.
|
|
rerender(<AnswerUiView block={chart(["一", "三"])} />);
|
|
expect(screen.getByRole("textbox", { name: "搜索压力" })).toBe(search);
|
|
expect(search).toHaveValue("一");
|
|
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
|
|
expect(screen.getByRole("tab", { name: "数据" })).toHaveAttribute("aria-selected", "true");
|
|
});
|
|
|
|
it("restores chart search and sort while discarding legacy row selection", () => {
|
|
const current = block("Chart", { title: "压力", chart_type: "line", x_data: ["08:00", "09:00", "10:00"], series: [{ name: "入口", data: [1, 2, 3] }, { name: "出口", data: [3, 4, 5] }], source: "测试" });
|
|
const changed = vi.fn();
|
|
const { rerender } = render(<AnswerUiView block={current} viewState={normalizeAnswerViewState(current.spec)} onViewStateChange={changed} />);
|
|
const restored = normalizeAnswerViewState(current.spec, { nodes: { node: { type: "Chart", view: "table", hidden: [1], range: [0, 1], point: 1, inspecting: true,
|
|
table: { query: "09:00", sort: { column: 1, descending: true }, selection: [1] } } } });
|
|
rerender(<AnswerUiView block={current} viewState={restored} onViewStateChange={changed} />);
|
|
expect(screen.getByRole("textbox", { name: "搜索压力" })).toHaveValue("09:00");
|
|
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
|
|
expect(screen.getByRole("columnheader", { name: "入口" })).toHaveAttribute("aria-sort", "descending");
|
|
expect(screen.queryByRole("columnheader", { name: "出口" })).not.toBeInTheDocument();
|
|
expect(changed).not.toHaveBeenCalled();
|
|
// A subsequent deliberate series change clears the derived table sort.
|
|
fireEvent.click(screen.getByRole("button", { name: "出口" }));
|
|
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
|
|
expect(screen.getByRole("textbox", { name: "搜索压力" })).toHaveValue("09:00");
|
|
expect(changed).toHaveBeenLastCalledWith(expect.anything(), ["/nodes/node/hidden", "/nodes/node/table/sort"]);
|
|
});
|
|
|
|
it("preserves form drafts during streaming repeats and drops a draft when the field kind changes", () => {
|
|
const { rerender } = render(<AnswerUiView block={form()} onSubmit={vi.fn()} />);
|
|
const input = screen.getByLabelText("现场记录");
|
|
fireEvent.change(input, { target: { value: "未发送" } });
|
|
rerender(<AnswerUiView block={form()} onSubmit={vi.fn()} />);
|
|
expect(screen.getByLabelText("现场记录")).toBe(input);
|
|
expect(input).toHaveValue("未发送");
|
|
rerender(<AnswerUiView block={form("number")} onSubmit={vi.fn()} />);
|
|
expect(screen.getByLabelText("现场记录")).toHaveValue(null);
|
|
});
|
|
|
|
it("restores evidence expansion and reports ambiguous chart label migration", () => {
|
|
const evidence = block("Evidence", { title: "依据", summary: "记录内容", source: "测试", kind: "observed", observed_at: null, evidence_id: null });
|
|
const saved = normalizeAnswerViewState(evidence.spec, { nodes: { node: { type: "Evidence", open: true } } });
|
|
const { unmount } = render(<AnswerUiView block={evidence} viewState={saved} />);
|
|
expect(screen.getByText("记录内容")).toBeVisible();
|
|
unmount();
|
|
const original = chart();
|
|
const state = normalizeAnswerViewState(original.spec);
|
|
if (state.nodes.node.type === "Chart") { state.nodes.node.point = 1; state.nodes.node.inspecting = true; }
|
|
const mounted = render(<AnswerUiView block={original} viewState={state} />);
|
|
mounted.rerender(<AnswerUiView block={chart(["一", "二", "二"])} />);
|
|
expect(screen.getByRole("status")).toHaveTextContent("无法唯一对应,已清除选择");
|
|
expect(screen.queryByRole("region", { name: "数据点详情" })).not.toBeInTheDocument();
|
|
});
|
|
});
|