import { act, fireEvent, render, screen, cleanup } from "@testing-library/react"; import { afterEach, expect, it, vi } from "vitest"; import type { AnswerViewStateRecord, AnswerViewStateWrite } from "../api/answer-view-state"; import { AnswerViewStateScope } from "../session-state/answer-view-state-scope"; import { AnswerViewPersistenceContext } from "../session-state/answer-view-persistence-context"; import type { AnswerUiBlock } from "./contract"; import { AnswerPartsView } from "./answer-parts-view"; vi.mock("../components/streaming-token-response", () => ({ StreamingTokenResponse: () => null })); afterEach(() => { cleanup(); vi.useRealTimers(); }); const block: AnswerUiBlock = { version: "answer-ui@1", block_id: "block", phase: "final", fallback_text: "资料", spec: { root: "form", elements: { form: { type: "FollowUpForm", children: [], props: { title: "资料", description: "补充", fields: [{ name: "note", label: "现场记录", kind: "text", required: false }] } } } } }; const remote: AnswerViewStateRecord = { version: "answer-view-state@1", message_id: "message", block_id: "block", revision: 4, schema_digest: "digest", updated_at: null, nodes: { form: { type: "FollowUpForm", values: { note: "远端" } } }, block: { evidenceFilter: "all" } }; function fixture() { let restore!: (value: AnswerViewStateRecord) => void; const client = { getAnswerViewState: vi.fn(() => new Promise((resolve) => { restore = resolve; })), putAnswerViewState: vi.fn(async (_session: string, write: AnswerViewStateWrite) => ({ ...remote, revision: 5, nodes: write.nodes, block: write.block })) }; const scope = new AnswerViewStateScope("actor:project", client); const view = (persist: boolean, value = block) => ; return { scope, client, view, restore: (record: AnswerViewStateRecord) => restore(record) }; } it("keeps the same form DOM and selection when persistence starts, preserving edits against late restore", async () => { vi.useFakeTimers(); const f = fixture(); const rendered = render(f.view(false)); const input = screen.getByLabelText("现场记录") as HTMLInputElement; fireEvent.change(input, { target: { value: "本地草稿" } }); input.focus(); input.setSelectionRange(1, 3); rendered.rerender(f.view(false, structuredClone(block))); expect(screen.getByLabelText("现场记录")).toBe(input); expect(f.client.getAnswerViewState).not.toHaveBeenCalled(); rendered.rerender(f.view(true, structuredClone(block))); expect(screen.getByLabelText("现场记录")).toBe(input); expect(input.value).toBe("本地草稿"); expect(input.selectionStart).toBe(1); expect(input.selectionEnd).toBe(3); expect(f.client.getAnswerViewState).toHaveBeenCalledTimes(1); await act(async () => f.restore(remote)); expect(input.value).toBe("本地草稿"); await act(async () => vi.advanceTimersByTimeAsync(500)); expect(f.client.putAnswerViewState.mock.calls[0][1]).toMatchObject({ expected_revision: 4, nodes: { form: { values: { note: "本地草稿" } } } }); rendered.unmount(); f.scope.dispose(); }); it("never reads or writes process blocks even when persistence is requested", async () => { vi.useFakeTimers(); const f = fixture(); const rendered = render(f.view(true, { ...block, phase: "process" })); fireEvent.change(screen.getByLabelText("现场记录"), { target: { value: "过程草稿" } }); expect(screen.queryByText("正在恢复交互状态")).toBeNull(); await act(async () => vi.advanceTimersByTimeAsync(2000)); rendered.unmount(); await act(async () => vi.advanceTimersByTimeAsync(1000)); expect(f.client.getAnswerViewState).not.toHaveBeenCalled(); expect(f.client.putAnswerViewState).not.toHaveBeenCalled(); f.scope.dispose(); });