feat: expand intelligent investigation workspace
Generic Container CI/CD / test-build-publish (push) Successful in 1m52s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m52s

This commit is contained in:
2026-10-30 17:30:19 +08:00
parent ddd03dbff6
commit c0e4231b83
223 changed files with 13155 additions and 5304 deletions
@@ -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 版");
});