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,53 @@
import { describe, expect, it } from "vitest";
import type { Spec } from "@json-render/core";
import { answerViewStateDirtyPaths, migrateAnswerViewState, normalizeAnswerViewState } from "./answer-view-state";
function spec(type: string, props: Record<string, unknown>): 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("clears row indices on reordered data, 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: [0] } }, 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"]);
});
});