import { beforeEach } from 'node:test'; import { questionLog } from './question-log.js'; beforeEach(t => { if (!('mock' in t)) throw new Error('Expected test context'); t.mock.method(questionLog, 'start', async () => 'synthetic-question'); t.mock.method(questionLog, 'finish', async () => {}); }); import assert from "node:assert/strict"; import test, { type TestContext } from "node:test"; import { Hono } from "hono"; import router from "./index.js"; import { vehicleRepository } from "../vehicles/repository.js"; import { gapStore } from "./gaps.js"; import type { AuthUser } from "../../auth/types.js"; import type { Vehicle } from "../../types.js"; import type { AssistantRequest, AssistantResult, QueryPlan, PresentationBlock, PresentationInteraction } from "../../../shared/assets/assistant.js"; const company = "生成式验收公司"; const plan = (overrides: Partial = {}): QueryPlan => ({ kind: "assets", category: "inventory", groupBy: "region", view: "chart", filters: { department: ["部门甲"] }, analysis: "distribution", ...overrides }); const based = (result: AssistantResult) => ({ sessionId: result.sessionId, baseResultId: result.resultId }); const ids = (result: AssistantResult) => result.rows.map((row) => row.id).sort(); const layout = (blocks: PresentationBlock[], interactions: PresentationInteraction[] = [], id = "main") => ({ id, title: "模型设计的分析视图", blocks, interactions }); const simple = (id = "main") => layout([{ id: "summary", kind: "summary" }], [], id); let serial = 0; function fixture(t: TestContext) { const user: AuthUser = { userId: `generative-${++serial}`, userName: "展示验收", loginName: "generative", depCode: "D", depName: "部门甲", permissionLevel: "personal", roles: [] }; const vehicles = [ { id: "east-45", type: "4.5T", city: "嘉兴市", status: "Inventory", operationStatus: "3" }, { id: "east-18", type: "18T", city: "杭州市", status: "Inventory", operationStatus: "3" }, { id: "east-abnormal", type: "4.5T", city: "嘉兴市", status: "Abnormal", operationStatus: "3", abnormalMove: { type: "维修", targetCompletionTime: "2020-01-01 12:00:00", actualCompletionTime: null } }, { id: "east-pending", type: "18T", city: "嘉兴市", status: "Pending", operationStatus: "4" }, { id: "south-45", type: "4.5T", city: "广州市", province: "广东省", status: "Inventory", operationStatus: "3" }, { id: "other-department", type: "4.5T", city: "嘉兴市", departmentName: "部门乙", status: "Inventory", operationStatus: "3" }, { id: "other-company", type: "4.5T", city: "嘉兴市", subjectOrg: "其他公司", status: "Inventory", operationStatus: "3" }, ].map((v) => ({ province: "浙江省", model: "合成车型", subjectOrg: company, departmentName: "部门甲", managerId: user.userId, plateNumber: `合成-${v.id}`, vin: `SYNTHETIC-${v.id}`, ...v }) as Vehicle); t.mock.method(vehicleRepository, "getVehicles", async () => vehicles); t.mock.method(vehicleRepository, "getFlowDetailRows", async (start: string, end: string) => [ { id: "day-17", truck_id: "east-45", stat_date: "2026-09-17", type: "delivered", type_label: "交车" }, { id: "day-18", truck_id: "south-45", stat_date: "2026-09-18", type: "returned", type_label: "还车" }, ].filter((row) => row.stat_date >= start && row.stat_date <= end)); t.mock.method(gapStore, "record", async () => {}); const oldKey = process.env.DEEPSEEK_API_KEY, oldEnabled = process.env.ASSET_AI_EXTERNAL_ENABLED; process.env.DEEPSEEK_API_KEY = "synthetic-test-key"; process.env.ASSET_AI_EXTERNAL_ENABLED = "1"; t.after(() => { if (oldKey === undefined) delete process.env.DEEPSEEK_API_KEY; else process.env.DEEPSEEK_API_KEY = oldKey; if (oldEnabled === undefined) delete process.env.ASSET_AI_EXTERNAL_ENABLED; else process.env.ASSET_AI_EXTERNAL_ENABLED = oldEnabled; }); const planning: Record[] = []; const presentations: (Record | Error)[] = []; const planningCalls: Record[] = []; const presentationCalls: Record[] = []; t.mock.method(globalThis, "fetch", async (_url: unknown, init?: RequestInit) => { const request = JSON.parse(String(init?.body)); const context = JSON.parse(request.messages.find((message: { role: string }) => message.role === "user").content); const isPresentation = context.stage === "presentation"; (isPresentation ? presentationCalls : planningCalls).push(context); const queue = isPresentation ? presentations : planning; assert.ok(queue.length, `Unexpected ${isPresentation ? "presentation" : "planning"} request`); const content = queue.shift()!; if (content instanceof Error) throw content; return new Response(JSON.stringify({ choices: [{ message: { content: JSON.stringify(content) }, finish_reason: "stop" }] }), { headers: { "Content-Type": "application/json" } }); }); const app = new Hono<{ Variables: { user: AuthUser } }>(); app.use("*", async (c, next) => { c.set("user", user); await next(); }); app.route("/ai", router); async function raw(input: Partial) { const response = await app.request("/ai/ask", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ subject: company, ...input }) }); const text = await response.text(); if (response.status !== 200) return { status: response.status, events: [], text }; const events = text.split("\n").filter((line) => line.startsWith("data:")).map((line) => JSON.parse(line.slice(5))); return { status: response.status, events, text }; } async function ask(input: Partial) { const response = await raw(input); assert.equal(response.status, 200, response.text); const error = response.events.find((event) => event.type === "error"); assert.equal(error, undefined, error?.message); const result = response.events.find((event) => event.type === "result")?.result as AssistantResult; assert.ok(result); return result; } return { planning, presentations, planningCalls, presentationCalls, ask, raw }; } test("the same regional group follows two model-designed paths rather than a fixed dimension order", async (t) => { const { planning, presentations, planningCalls, presentationCalls, ask } = fixture(t); planning.push({ kind: "query", plan: plan() }); presentations.push({ layout: "stack", results: [layout([ { id: "types-chart", kind: "chart", dataset: "groups", chart: "bar", series: [{ field: "count", label: "库存数量" }], interactionId: "by-type" }, { id: "abnormal-chart", kind: "chart", dataset: "groups", chart: "donut", series: [{ field: "count", label: "库存数量" }], interactionId: "abnormal-list" }, ], [ { id: "by-type", label: "按车型拆分这个区域", kind: "group", plan: plan({ groupBy: "type", filters: {} }) }, { id: "abnormal-list", label: "直接查看这个区域的异动清单", kind: "group", plan: plan({ category: "abnormal", groupBy: "none", view: "list", filters: {} }) }, ])] }); const root = await ask({ message: "部门甲的库存按区域看,并提供车型和异动两个分析方向。" }); assert.equal(root.presentationStatus, "generated"); assert.equal(root.presentation?.blocks.length, 2); presentations.push({ layout: "stack", results: [simple()] }); const types = await ask({ ...based(root), action: { type: "explore", interactionId: "by-type", keys: { region: "华东" } } }); assert.equal(types.plan.groupBy, "type"); assert.deepEqual(types.plan.filters, { department: ["部门甲"], region: ["华东"] }); assert.equal(types.rows.length, 4); assert.equal(types.presentationStatus, "generated"); presentations.push({ layout: "stack", results: [layout([{ id: "detail", kind: "table", dataset: "rows", columns: [{ field: "abnormalType", label: "异动类型" }, { field: "plate", label: "车辆" }] }])] }); const abnormal = await ask({ ...based(root), action: { type: "explore", interactionId: "abnormal-list", keys: { region: "华东" } } }); assert.equal(abnormal.plan.category, "abnormal"); assert.equal(abnormal.plan.groupBy, "none"); assert.equal(abnormal.plan.view, "list"); assert.deepEqual(abnormal.plan.filters, types.plan.filters); assert.deepEqual(ids(abnormal), ["east-abnormal"]); assert.equal(abnormal.rows[0].abnormalType, "维修"); assert.equal(planningCalls.length, 1, "Clicks execute stored model decisions without a new planning pass"); assert.equal(presentationCalls.length, 3, "Each changed dataset gets one new display design"); }); test("presentation preserves real metric references and model-selected column order", async (t) => { const { planning, presentations, ask, presentationCalls } = fixture(t); planning.push({ kind: "query", plan: plan() }); presentations.push({ layout: "stack", results: [layout([ { id: "metrics", kind: "metrics", metrics: ["其中待交车", "库存车辆"] }, { id: "table", kind: "table", dataset: "groups", columns: [{ field: "count", label: "车辆数量" }, { field: "name", label: "区域名称" }] }, ])] }); const result = await ask({ message: "展示部门甲库存指标和统计表,表格把数量放在区域前面。" }); assert.equal(result.presentationStatus, "generated"); assert.deepEqual(result.presentation?.blocks[0].metrics, ["其中待交车", "库存车辆"]); assert.deepEqual(result.presentation?.blocks[1].columns, [{ field: "count", label: "车辆数量" }, { field: "name", label: "区域名称" }]); assert.equal(result.metrics.find((metric) => metric.label === "库存车辆")?.value, 5); assert.equal(result.metrics.find((metric) => metric.label === "其中待交车")?.value, 1); assert.match(JSON.stringify(presentationCalls[0]), /库存车辆/); }); test("unknown interaction identifiers and invented group keys cannot execute or generate a result", async (t) => { const { planning, presentations, ask, raw, planningCalls, presentationCalls } = fixture(t); planning.push({ kind: "query", plan: plan() }); presentations.push({ layout: "stack", results: [layout([{ id: "regions", kind: "chart", dataset: "groups", chart: "bar", series: [{ field: "count", label: "库存" }], interactionId: "drill" }], [{ id: "drill", label: "按车型查看", kind: "group", plan: plan({ groupBy: "type" }) }])] }); const root = await ask({ message: "看看部门甲各区域库存" }); for (const action of [ { type: "explore" as const, interactionId: "not-issued", keys: { region: "华东" } }, { type: "explore" as const, interactionId: "drill", keys: { region: "不存在的地区" } }, { type: "explore" as const, interactionId: "drill", keys: { region: "华东", city: "嘉兴市" } }, ]) { const failed = await raw({ ...based(root), action }); assert.ok(failed.status >= 400 || failed.events.some((event) => event.type === "error")); assert.ok(!failed.events.some((event) => event.type === "result")); } assert.equal(planningCalls.length, 1); assert.equal(presentationCalls.length, 1); }); test("multiple sections share one layout call and cannot use another section's interaction", async (t) => { const { planning, presentations, ask, raw, planningCalls, presentationCalls } = fixture(t); const stockPlan = plan(), pendingPlan = plan({ category: "pending", view: "list" }); planning.push({ kind: "query", plan: stockPlan, queries: [ { id: "stock", title: "库存区域分布", question: "部门甲库存按区域", plan: stockPlan, display: "chart" }, { id: "pending", title: "待交车清单", question: "部门甲待交车清单", plan: pendingPlan, display: "list" }, ] }); presentations.push({ layout: "tabs", results: [ layout([{ id: "stock-chart", kind: "chart", dataset: "groups", chart: "donut", series: [{ field: "count", label: "库存数量" }], interactionId: "stock-only" }], [{ id: "stock-only", label: "车型细分", kind: "group", plan: plan({ groupBy: "type" }) }], "stock"), layout([{ id: "pending-table", kind: "table", dataset: "rows", columns: [{ field: "plate", label: "车牌" }, { field: "type", label: "车型" }] }], [], "pending"), ] }); const root = await ask({ message: "库存分布和待交车清单分开展示,使用页签。" }); assert.equal(root.layout, "tabs"); assert.equal(root.sections?.length, 2); assert.ok(root.sections!.every((section) => section.result?.presentationStatus === "generated")); assert.equal(root.sections![0].result?.presentation?.blocks[0].chart, "donut"); assert.equal(root.sections![1].result?.presentation?.blocks[0].dataset, "rows"); assert.equal(planningCalls.length, 1); assert.equal(presentationCalls.length, 1); const wrongSection = root.sections!.find((section) => section.id === "pending")!.result!; const rejected = await raw({ ...based(wrongSection), action: { type: "explore", interactionId: "stock-only", keys: { region: "华东" } } }); assert.ok(rejected.status >= 400 || rejected.events.some((event) => event.type === "error")); assert.equal(presentationCalls.length, 1); }); test("a failed display generation preserves computed data and explicitly marks fallback", async (t) => { const { planning, presentations, ask, planningCalls, presentationCalls } = fixture(t); planning.push({ kind: "query", plan: plan() }); presentations.push(new Error("Synthetic rendering model failure")); const result = await ask({ message: "部门甲库存按区域展示" }); assert.equal(result.rows.length, 5); assert.equal(result.metrics.find((metric) => metric.label === "库存车辆")?.value, 5); assert.equal(result.presentationStatus, "fallback"); assert.ok(result.presentationWarning); assert.notEqual(result.outcome, "unsupported"); assert.equal(result.gap?.reason, "generation"); assert.equal(result.gap.recorded, true); assert.equal(planningCalls.length, 1); assert.equal(presentationCalls.length, 1); assert.doesNotMatch(JSON.stringify(result), /Synthetic rendering model failure/); }); test("a model-designed daily point action fixes the selected date and regenerates its layout", async (t) => { const { planning, presentations, ask, planningCalls, presentationCalls } = fixture(t); const flowPlan = plan({ kind: "flow", category: "all", groupBy: "none", start: "2026-09-17", end: "2026-09-18" }); planning.push({ kind: "query", plan: flowPlan }); presentations.push({ layout: "stack", results: [layout([{ id: "flow", kind: "chart", dataset: "daily", chart: "line", series: [{ field: "delivered", label: "交车" }, { field: "returned", label: "还车" }], interactionId: "day-events" }], [{ id: "day-events", label: "查看该日业务明细", kind: "point", plan: { ...flowPlan, view: "list" } }])] }); const root = await ask({ message: "部门甲2026年9月17日至18日交退换趋势" }); presentations.push({ layout: "stack", results: [layout([{ id: "events", kind: "table", dataset: "rows", columns: [{ field: "date", label: "日期" }, { field: "event", label: "业务" }, { field: "plate", label: "车辆" }] }])] }); const day = await ask({ ...based(root), action: { type: "explore", interactionId: "day-events", date: "2026-09-17" } }); assert.equal(day.plan.start, "2026-09-17"); assert.equal(day.plan.end, "2026-09-17"); assert.deepEqual(day.plan.filters, flowPlan.filters); assert.deepEqual(ids(day), ["day-17"]); assert.equal(day.presentationStatus, "generated"); assert.equal(planningCalls.length, 1); assert.equal(presentationCalls.length, 2); }); test("narrative metric placeholders resolve to computed values and fabricated numeric prose is omitted", async (t) => { const { planning, presentations, ask } = fixture(t); planning.push({ kind: "query", plan: plan() }); presentations.push({ layout: "stack", results: [layout([ { id: "grounded", kind: "narrative", text: "当前库存{{metric:库存车辆}},其中待交车{{metric:其中待交车}}。" }, { id: "fabricated", kind: "narrative", text: "库存已经达到9999台。" }, ])] }); const result = await ask({ message: "部门甲库存给我一段可核对的数字解读" }); assert.equal(result.presentationStatus, "generated"); assert.equal(result.presentation?.blocks.find((block) => block.id === "grounded")?.text, "当前库存5台,其中待交车1台。"); assert.ok(!result.presentation?.blocks.some((block) => block.id === "fabricated")); assert.ok(result.presentationWarning); assert.doesNotMatch(JSON.stringify(result.presentation), /9999/); }); test("one section's missing presentation keeps all data and the sibling's generated layout", async (t) => { const { planning, presentations, ask, presentationCalls } = fixture(t); planning.push({ kind: "query", plan: plan(), queries: [ { id: "stock", title: "库存区域分布", question: "部门甲库存按区域", plan: plan(), display: "chart" }, { id: "pending", title: "待交车清单", question: "部门甲待交车清单", plan: plan({ category: "pending", view: "list" }), display: "list" }, ] }); presentations.push({ layout: "stack", results: [simple("stock")] }); const result = await ask({ message: "分别展示部门甲库存分布和待交车清单" }); const stock = result.sections!.find((section) => section.id === "stock")!.result!; const pending = result.sections!.find((section) => section.id === "pending")!.result!; assert.equal(stock.presentationStatus, "generated"); assert.equal(stock.rows.length, 5); assert.equal(pending.presentationStatus, "fallback"); assert.deepEqual(ids(pending), ["east-pending"]); assert.ok(pending.presentationWarning); assert.equal(result.outcome, "partial"); assert.equal(result.gap?.reason, "generation"); assert.ok(result.unanswered?.some((item) => /待交车/.test(item.question) && /展示|生成/.test(item.reason))); assert.equal(presentationCalls.length, 1); });