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: () =>
})); function block(type: string, props: Record, 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(); 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(); fireEvent.change(screen.getByLabelText("现场记录"), { target: { value: "尚未提交的草稿" } }); expect(changed).toHaveBeenLastCalledWith(expect.objectContaining({ nodes: { node: { type: "FollowUpForm", values: { record: "尚未提交的草稿" } } } }), ["/nodes/node/values/record"]); rendered.unmount(); render(); 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(); 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(); 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(); 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(); 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(); const input = screen.getByLabelText("现场记录"); fireEvent.change(input, { target: { value: "未发送" } }); rerender(); expect(screen.getByLabelText("现场记录")).toBe(input); expect(input).toHaveValue("未发送"); rerender(); 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(); 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(); mounted.rerender(); expect(screen.getByRole("status")).toHaveTextContent("无法唯一对应,已清除选择"); expect(screen.queryByRole("region", { name: "数据点详情" })).not.toBeInTheDocument(); }); });