import { describe, expect, it, vi } from "vitest"; import type { InvestigationPresentationHistoryEvent } from "../api/investigation-presentation"; import type { InvestigationMapPresentation } from "../map/investigation-map-presentation"; import { presentationHistoryPorts } from "../map/presentation-history-ports"; import { hydrateInvestigationPresentationArchive } from "./investigation-presentation-archive"; import { PresentationViewHistory } from "./presentation-view-history"; const task = { task_id: "50000000-0000-4000-8000-000000000001", title: "已保存调查", execution_generation: 2, evidence: [], latest_report: null }; const chart = (title: string) => ({ action: "open_chart", chart_id: "pressure", evidence_refs: ["reading"], chart: { title, chart_type: "line", x_data: ["08:00"], series: [{ name: "压力", data: [20] }] } }); const event = ( event_seq: number, command: unknown, execution_generation = 2 ): InvestigationPresentationHistoryEvent => ({ task_id: task.task_id, event_seq, run_id: "55000000-0000-4000-8000-000000000001", execution_generation, occurred_at: "2026-10-27T08:00:00Z", command }); function setup() { const history = new PresentationViewHistory(); const scene = { highlight: vi.fn(), clearHighlights: vi.fn(), setOverlay: vi.fn(), clearOverlay: vi.fn(), renderScadaAnalysis: vi.fn(), clearScadaAnalysis: vi.fn() }; const controller = { queryPresentationFeatures: vi.fn(async () => ({ type: "FeatureCollection" as const, features: [ { type: "Feature" as const, properties: { device_id: "P1" }, geometry: { type: "Point" as const, coordinates: [121.4, 31.1] } } ] })) }; const createPorts = (staging: PresentationViewHistory) => presentationHistoryPorts({ controller, scene: scene as unknown as InvestigationMapPresentation, map: () => null, padding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }), update: (updater) => staging.update(updater(staging.current)), rendering: () => false, cameraAllowed: () => true, waitForChart: async () => undefined, frame: async () => undefined }); const load = ( events: InvestigationPresentationHistoryEvent[], signal = new AbortController().signal ) => hydrateInvestigationPresentationArchive({ events, task, history, createPorts, signal }); return { history, scene, controller, load, createPorts }; } describe("saved investigation presentation", () => { it("hydrates sequential snapshots, applies cleanup without history rows and deduplicates task reselection", async () => { const test = setup(); const events = [ event(1, { action: "highlight_features", layer: "scada", feature_ids: ["P1"], effect: "pulse" }), event(2, chart("原始压力")), event(3, { action: "close_chart", chart_id: "pressure" }), event(4, { action: "clear_highlights" }), event(5, chart("最终压力")) ]; const current = await test.load(events); expect(current.charts[0].chart.title).toBe("最终压力"); expect(current.highlights.features).toHaveLength(0); expect(test.history.entries).toHaveLength(4); expect(test.history.entries[1].snapshot.charts[0].chart.title).toBe("原始压力"); expect(test.history.entries[1].snapshot.highlights.features).toHaveLength(1); const again = await test.load(events); expect(again.charts[0].chart.title).toBe("最终压力"); expect(test.history.entries).toHaveLength(4); expect(test.controller.queryPresentationFeatures).toHaveBeenCalledTimes(1); expect(Object.values(test.scene).every((fn) => fn.mock.calls.length === 0)).toBe(true); }); it("retains prior generations as history without leaking their charts into the current generation", async () => { const test = setup(); const next = await test.load([event(1, chart("上一轮"), 1), event(2, chart("本轮"))]); expect(next.charts.map((item) => item.chart.title)).toEqual(["本轮"]); expect(test.history.entries[0].taskTitle).toContain("第 1 轮"); const previousOnly = await test.load([event(1, chart("上一轮"), 1)]); expect(previousOnly.charts).toEqual([]); }); it("rejects unregistered commands individually and refuses another task's records", async () => { const test = setup(); const current = await test.load([ event(1, { action: "execute", command: "unsafe" }), event(2, chart("有效曲线")) ]); expect(test.history.entries.map((item) => item.status)).toEqual(["error", "completed"]); expect(current.charts[0].chart.title).toBe("有效曲线"); await expect( test.load([ { ...event(3, chart("错误任务")), task_id: "50000000-0000-4000-8000-000000000002" } ]) ).rejects.toThrow("不属于所选调查"); }); it("does not append late results after a task switch aborts a feature lookup", async () => { const test = setup(); let done!: () => void; const pending = new Promise((resolve) => { done = resolve; }); test.controller.queryPresentationFeatures.mockImplementationOnce(async () => { await pending; return { type: "FeatureCollection", features: [] }; }); const controller = new AbortController(); const loading = test.load( [event(1, { action: "locate_features", layer: "scada", feature_ids: ["P1"] })], controller.signal ); controller.abort(); done(); await expect(loading).rejects.toThrow(); expect(test.history.entries).toHaveLength(0); }); }); it("loads saved report blocks without display events and does not duplicate them on reselection", async () => { const test = setup(); const reportTask = { ...task, latest_report: { revision: 3, execution_generation: 2, source_session_id: "saved", published_by: "agent", published_at: "2026-10-27T08:00:00Z", report: { title: "保存的调查报告", current_conclusion: "待现场核查", conclusion_boundary: "合成测试", evidence_refs: [], recommendations: [], pending_items: [], ui_blocks: [ { evidence_refs: [], block: { version: "answer-ui@1" as const, block_id: "saved-valves", phase: "final" as const, fallback_text: "阀门数据", spec: { root: "note", elements: { note: { type: "Text" as const, props: { text: "已保存阀门核查结果", tone: "neutral" as const }, children: [] } } } } } ] } } }; const first = await hydrateInvestigationPresentationArchive({ events: [], task: reportTask, history: test.history, createPorts: () => ({}) as never, signal: new AbortController().signal }); expect(first.blocks[0].block_id).toBe("saved-valves"); const second = await hydrateInvestigationPresentationArchive({ events: [], task: reportTask, history: test.history, createPorts: () => ({}) as never, signal: new AbortController().signal }); expect(second.blocks).toBe(first.blocks); expect(test.history.entries).toHaveLength(1); expect(test.history.entries[0].label).toContain("报告第 3 版"); });