import { describe, expect, it } from "vitest"; import type { Spec } from "@json-render/core"; import { answerChartTableData, answerViewStateDirtyPaths, migrateAnswerViewState, normalizeAnswerViewState } from "./answer-view-state"; function spec(type: string, props: Record): Spec { return { root: "node", elements: { node: { type, props, children: [] } } }; } const chart = (labels = ["a", "b", "c"], names = ["入口", "出口"]) => spec("Chart", { chart_type: "line", x_data: labels, series: names.map((name) => ({ name, data: labels.map((_, index) => index) })) }); const table = (rows = [["a", "1"], ["b", "2"]], columns = ["对象", "读数"]) => spec("Table", { rows, columns }); const form = (fields = [{ name: "record", kind: "text" }]) => spec("FollowUpForm", { fields }); describe("answer view state compatibility", () => { it("uses the same UTC8 sample display in chart tables while preserving stored samples", () => { const series = [{ name: "压力", data: [20], sample_times: ["2026-07-16T00:00:00Z"] }]; const current = spec("Chart", { chart_type: "line", x_data: ["08:00"], series }); const state = normalizeAnswerViewState(current).nodes.node; if (state.type !== "Chart") throw new Error("Expected chart state"); expect(answerChartTableData(current.elements.node, state).rows[0][1]).toBe("20 (2026-07-16 08:00:00+08:00)"); expect(series[0].sample_times[0]).toBe("2026-07-16T00:00:00Z"); }); it("ignores legacy row selection, retains search, and clears sort when columns change", () => { const before = table(); const state = normalizeAnswerViewState(before, { nodes: { node: { type: "Table", query: "a", sort: { column: 1, descending: true }, selection: [0] } } }); expect(migrateAnswerViewState(before, table([["b", "2"], ["a", "1"]]), state).nodes.node).toEqual({ type: "Table", query: "a", sort: { column: 1, descending: true }, selection: [] }); expect(migrateAnswerViewState(before, table(undefined, ["读数", "对象"]), state).nodes.node).toMatchObject({ query: "a", sort: null, selection: [] }); expect(migrateAnswerViewState(before, structuredClone(before), state)).toEqual(state); }); it("relocates chart ranges and points only by unique labels and resets hidden series after renaming", () => { const before = chart(); const state = normalizeAnswerViewState(before, { nodes: { node: { type: "Chart", view: "bar", hidden: [1], range: [1, 2], point: 2, inspecting: true, table: { query: "b", sort: { column: 1, descending: false }, selection: [0] } } } }); expect(migrateAnswerViewState(before, chart(["z", "a", "b", "c"]), state).nodes.node).toMatchObject({ view: "bar", hidden: [1], range: [2, 3], point: 3, inspecting: true, table: { query: "b", selection: [] } }); expect(migrateAnswerViewState(before, chart(undefined, ["出口", "入口"]), state).nodes.node).toMatchObject({ view: "bar", hidden: [], table: { query: "b", sort: null, selection: [] } }); for (const labels of [["a", "b", "b"], ["a", "b"]]) expect(migrateAnswerViewState(before, chart(labels), state).nodes.node).toMatchObject({ range: [0, labels.length - 1], point: 0, inspecting: false }); }); it("preserves same-name same-kind drafts across equivalent streaming schemas and drops replaced fields", () => { const before = form([{ name: "record", kind: "text" }, { name: "deleted", kind: "text" }]); const state = normalizeAnswerViewState(before, { nodes: { node: { type: "FollowUpForm", values: { record: "尚未提交", deleted: "旧值" } } } }); expect(migrateAnswerViewState(before, structuredClone(before), state)).toEqual(state); expect(migrateAnswerViewState(before, form([{ name: "record", kind: "number" }, { name: "added", kind: "text" }]), state).nodes.node).toEqual({ type: "FollowUpForm", values: {} }); expect(migrateAnswerViewState(before, form(), state).nodes.node).toEqual({ type: "FollowUpForm", values: { record: "尚未提交" } }); }); it("only accepts supported state, bounds indices, preserves draft text, and resets missing evidence filters", () => { const current = table(); const result = normalizeAnswerViewState(current, { nodes: { node: { type: "Table", query: "查".repeat(2000), sort: { column: 99, descending: true }, selection: [-1, 0, 0, 2] }, unknown: { type: "Evidence", open: true } }, block: { evidenceFilter: "gone" }, action: "ignored" }); expect(result).toEqual({ nodes: { node: { type: "Table", query: "查".repeat(2000), sort: null, selection: [] } }, block: { evidenceFilter: "all" } }); }); it("tracks form and nested chart-table edits at field granularity", () => { const current = form([{ name: "record", kind: "text" }, { name: "other", kind: "text" }]); const before = normalizeAnswerViewState(current); const after = normalizeAnswerViewState(current, { nodes: { node: { type: "FollowUpForm", values: { record: "草稿" } } } }); expect(answerViewStateDirtyPaths(before, after)).toEqual(["/nodes/node/values/record"]); const chartState = normalizeAnswerViewState(chart()); const updated = structuredClone(chartState); if (updated.nodes.node.type === "Chart") updated.nodes.node.table!.query = "a"; expect(answerViewStateDirtyPaths(chartState, updated)).toEqual(["/nodes/node/table/query"]); }); });