feat: expand intelligent investigation workspace
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
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<void>((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 版");
|
||||
});
|
||||
Reference in New Issue
Block a user