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, GapReason, QueryPlan } from "../../../shared/assets/assistant.js"; const company = "合成公司A"; const plan = (overrides: Partial = {}): QueryPlan => ({ kind: "assets", category: "inventory", groupBy: "city", view: "table", filters: { region: ["华东"] }, analysis: "distribution", ...overrides, }); const part = (id: string, title: string, queryPlan: QueryPlan, display = "table") => ({ id, title, question: title, plan: queryPlan, display }); let serial = 0; function fixture(t: TestContext, permissionLevel: AuthUser["permissionLevel"] = "personal") { const user: AuthUser = { userId: `composition-${++serial}`, userName: "组合验收", loginName: "composition", depCode: "D", depName: "合成部门", permissionLevel, roles: [] }; const vehicles = [ { id: "jx-inventory", type: "4.5T", city: "嘉兴市", status: "Inventory", operationStatus: "3" }, { id: "hz-inventory", type: "18T", city: "杭州市", status: "Inventory", operationStatus: "3" }, { id: "jx-pending", type: "4.5T", city: "嘉兴市", status: "Pending", operationStatus: "4" }, { id: "jx-operating", type: "18T", city: "嘉兴市", status: "Operating", operationStatus: "1" }, { id: "south-inventory", type: "49T", city: "广州市", province: "广东省", status: "Inventory", operationStatus: "3" }, { id: "other-company", type: "4.5T", city: "嘉兴市", subjectOrg: "合成公司B", status: "Inventory", operationStatus: "3" }, { id: "other-owner", type: "4.5T", city: "嘉兴市", managerId: "another-user", departmentName: "其他部门", status: "Inventory", operationStatus: "3" }, ].map((v) => ({ province: "浙江省", model: "合成车型", subjectOrg: company, departmentName: "合成部门", managerId: user.userId, plateNumber: `合成-${v.id}`, vin: `SYNTHETIC-${v.id}`, ...v }) as Vehicle); const getVehicles = t.mock.method(vehicleRepository, "getVehicles", async () => vehicles); let flowFailure = false; const getFlow = t.mock.method(vehicleRepository, "getFlowDetailRows", async (start: string) => { if (flowFailure) throw new Error("Synthetic internal database error; must not become user output"); return [ { id: "event-valid", truck_id: "jx-inventory", stat_date: start, type: "delivered", type_label: "交车" }, { id: "event-other-company", truck_id: "other-company", stat_date: start, type: "delivered", type_label: "交车" }, { id: "event-other-owner", truck_id: "other-owner", stat_date: start, type: "delivered", type_label: "交车" }, ]; }); const gaps: { demand: string; reason: GapReason }[] = []; t.mock.method(gapStore, "record", async (_user: string, demand: string, reason: GapReason) => { gaps.push({ demand, reason }); }); const oldKey = process.env.DEEPSEEK_API_KEY, oldEnabled = process.env.ASSET_AI_EXTERNAL_ENABLED; process.env.DEEPSEEK_API_KEY = "synthetic-model-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 proposals: Record[] = []; const presentationRequests: string[] = []; // Existing assertions count planning calls; presentation is an independent stage. const requests: string[] = []; t.mock.method(globalThis, "fetch", async (_url: unknown, init?: RequestInit) => { const body = JSON.parse(String(init?.body)); const context = JSON.parse(body.messages.find((message: { role: string }) => message.role === "user").content); if (context.stage === "presentation") { presentationRequests.push(String(init?.body)); const presentation = { layout: "stack", results: context.results.map((result: { id: string }) => ({ id: result.id, blocks: [{ id: "summary", kind: "summary" }], interactions: [] })) }; return new Response(JSON.stringify({ choices: [{ message: { content: JSON.stringify(presentation) }, finish_reason: "stop" }] })); } requests.push(String(init?.body)); assert.ok(proposals.length, "Unexpected external request: every response is supplied by this test"); return new Response(JSON.stringify({ choices: [{ message: { content: JSON.stringify(proposals.shift()) }, 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 ask(input: Partial) { const response = await app.request("/ai/ask", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ subject: company, ...input }) }); assert.equal(response.status, 200); const events = (await response.text()).split("\n").filter((line) => line.startsWith("data:")).map((line) => JSON.parse(line.slice(5))); const error = events.find((event) => event.type === "error"); assert.equal(error, undefined, error?.message); const result = events.find((event) => event.type === "result")?.result as AssistantResult; assert.ok(result, "A composed response must produce a result even if one section fails"); return result; } return { app, ask, proposals, requests, presentationRequests, gaps, getVehicles, getFlow, vehicles, failFlow: () => { flowFailure = true; } }; } const based = (result: AssistantResult) => ({ sessionId: result.sessionId, baseResultId: result.resultId }); const ids = (result: AssistantResult) => result.rows.map((row) => row.id).sort(); const inventory = () => part("inventory", "华东库存按城市分布", plan(), "chart"); const flow = () => part("flow", "华东本期交退换趋势", plan({ kind: "flow", category: "all", groupBy: "none", start: "2026-09-17", end: "2026-09-23" }), "chart"); test("one model decision executes four independent analyses in the same authorized company scope", async (t) => { const { ask, proposals, requests, presentationRequests, getVehicles } = fixture(t); const sections = [ part("overview", "华东全部资产概况", plan({ category: "all" }), "metrics"), inventory(), part("pending", "华东待交车清单", plan({ category: "pending", view: "list" }), "list"), part("operating", "华东运营车辆按车型统计", plan({ category: "operating", groupBy: "type" }), "table"), ]; proposals.push({ kind: "query", plan: sections[0].plan, queries: sections }); const result = await ask({ message: "分别展示华东资产概况、库存分布、待交车清单和运营车型统计。" }); assert.equal(result.sections?.length, 4); assert.ok(result.resultId); const counts = [5, 4, 1, 1]; const childIds: string[] = []; for (const [index, section] of result.sections!.entries()) { assert.equal(section.id, sections[index].id); assert.equal(section.display, sections[index].display); assert.equal(section.error, undefined); assert.ok(section.result); assert.equal(section.result.rows.length, counts[index]); assert.equal(section.result.scope?.company, company); assert.deepEqual(section.result.plan.filters, { region: ["华东"] }); assert.equal(section.result.sessionId, result.sessionId); assert.ok(section.result.resultId); childIds.push(section.result.resultId!); assert.ok(ids(section.result).every((id) => !["other-company", "south-inventory"].includes(id))); } assert.equal(new Set([result.resultId, ...childIds]).size, 5, "Root and each analysis need distinct result identities"); assert.equal(result.outcome, "complete"); assert.equal(requests.length, 1); assert.equal(presentationRequests.length, 1, "One display generation call serves all four analyses"); assert.equal(getVehicles.mock.callCount(), 1, "All analyses must share a single authorized vehicle snapshot"); }); for (const failedFirst of [true, false]) { test(`a ${failedFirst ? "primary" : "secondary"} flow failure preserves the successful asset analysis`, async (t) => { const { ask, proposals, gaps, failFlow, requests } = fixture(t); failFlow(); const queries = failedFirst ? [flow(), inventory()] : [inventory(), flow()]; proposals.push({ kind: "query", plan: queries[0].plan, queries }); const result = await ask({ message: "华东库存和本期交退换分别展示,哪个暂不可用就保留其余结果。" }); assert.equal(result.sections?.length, 2); const asset = result.sections!.find((section) => section.id === "inventory")!; const failed = result.sections!.find((section) => section.id === "flow")!; assert.equal(asset.result?.rows.length, 4); assert.ok(asset.result?.resultId); assert.ok(failed.error); assert.equal(failed.result, undefined); assert.equal(result.outcome, "partial"); assert.ok(result.unanswered?.some((item) => /交退换|流转/.test(item.question))); assert.ok(gaps.some((gap) => /交退换|流转/.test(gap.demand))); assert.doesNotMatch(JSON.stringify(result), /Synthetic internal database/); assert.equal(requests.length, 1); }); } test("drilling and following up a child preserves that child's category and filters", async (t) => { const { ask, proposals, requests, presentationRequests } = fixture(t); proposals.push({ kind: "query", plan: plan({ category: "all" }), queries: [part("overview", "华东全部资产", plan({ category: "all" }), "metrics"), inventory()] }); const composition = await ask({ message: "华东资产概览和库存分布分开给我。" }); const child = composition.sections!.find((section) => section.id === "inventory")!.result!; const drill = await ask({ ...based(child), action: { type: "drill", keys: { city: "嘉兴市" } } }); assert.equal(drill.sections, undefined, "A local block action must return a normal result for in-place replacement"); assert.equal(drill.plan.category, "inventory"); assert.deepEqual(drill.plan.filters, { region: ["华东"], city: ["嘉兴市"] }); assert.deepEqual(ids(drill), ["jx-inventory", "jx-pending", "other-owner"]); assert.equal(requests.length, 1, "Click-based drilling should not trigger another planning request"); assert.equal(presentationRequests.length, 2, "The original result and clicked result each get one display generation pass"); proposals.push({ kind: "query", plan: plan({ category: "pending", view: "list", filters: { region: ["华东"], city: ["嘉兴市"] } }) }); const follow = await ask({ ...based(drill), message: "这里面待交车的单独列出来" }); assert.equal(follow.sections, undefined); assert.deepEqual(ids(follow), ["jx-pending"]); assert.deepEqual(follow.plan.filters, drill.plan.filters); assert.equal(follow.breadcrumbs?.at(-2)?.resultId, drill.resultId); }); test("the parent conversation includes all successful analyses for cross-section references", async (t) => { const { ask, proposals, requests } = fixture(t); proposals.push({ kind: "query", plan: plan(), queries: [inventory(), part("pending", "华东待交车清单", plan({ category: "pending", view: "list" }), "list")] }); const root = await ask({ message: "华东库存分布和待交车清单分开显示。" }); const pending = root.sections!.find((section) => section.id === "pending")!.result!; proposals.push({ kind: "query", plan: plan({ category: "pending", groupBy: "type" }), baseResultId: pending.resultId }); const next = await ask({ ...based(root), message: "继续第二项的待交车,按车型拆开" }); const request = JSON.parse(requests.at(-1)!); const context = JSON.parse([...request.messages].reverse().find((message: { role: string }) => message.role === "user").content); const analyses = context.analyses || context.resultContext?.analyses; assert.ok(Array.isArray(analyses), "The model needs every analysis, not only the root's first plan"); assert.equal(analyses.length, 2); assert.ok(analyses.some((analysis: { plan: QueryPlan; metrics: { value: number }[] }) => analysis.plan.category === "inventory" && analysis.metrics[0].value === 4)); assert.ok(analyses.some((analysis: { id: string; plan: QueryPlan; metrics: { value: number }[] }) => analysis.id === pending.resultId && analysis.plan.category === "pending" && analysis.metrics[0].value === 1)); assert.equal(next.plan.category, "pending"); assert.deepEqual(ids(next), ["jx-pending"]); assert.equal(next.breadcrumbs?.at(-2)?.resultId, pending.resultId); }); test("an unknown display or invalid child plan cannot discard the healthy sibling", async (t) => { const { ask, proposals, requests, gaps } = fixture(t); proposals.push({ kind: "query", plan: plan(), queries: [ inventory(), part("unknown-display", "待交车清单", plan({ category: "pending", view: "list" }), "javascript:unsupported-renderer"), { ...part("invalid-plan", "未能识别的业务查询", plan()), plan: { ...plan(), category: "does-not-exist" } }, ] }); const result = await ask({ message: "库存分布、待交清单和另外一项查询分别显示。" }); const healthy = result.sections?.find((section) => section.id === "inventory")?.result || result; assert.equal(healthy.rows.length, 4); assert.equal(healthy.plan.category, "inventory"); const display = result.sections?.find((section) => section.id === "unknown-display"); if (display) { assert.ok(["metrics", "chart", "table", "list"].includes(display.display)); assert.ok(display.error || display.result?.plan.category === "pending"); } else { assert.ok(result.unanswered?.some((item) => item.question === "待交车清单"), "Discarded presentation must have an explicit unanswered item"); } const invalid = result.sections?.find((section) => section.id === "invalid-plan"); assert.ok(invalid?.error || result.unanswered?.some((item) => item.question === "未能识别的业务查询"), "Invalid query must be individually explained, either as a failed section or an unanswered item"); assert.equal(result.outcome, "partial"); assert.ok(result.unanswered?.length); assert.ok(gaps.some((gap) => /未能识别的业务查询/.test(gap.demand))); assert.equal(requests.length, 1, "An invalid section must not cause a blanket second planning pass"); }); test("a simple query keeps the legacy result shape and uses one planning call", async (t) => { const { ask, proposals, requests, presentationRequests, getVehicles, getFlow } = fixture(t); proposals.push({ kind: "query", plan: plan() }); const result = await ask({ message: "华东库存按城市统计" }); assert.equal(result.sections, undefined); assert.equal(result.rows.length, 4); assert.equal(result.metrics[0].value, 4); assert.ok(result.resultId); assert.equal(requests.length, 1); assert.equal(presentationRequests.length, 1); assert.equal(getVehicles.mock.callCount(), 1); assert.equal(getFlow.mock.callCount(), 0); }); test("inventory subset percentages use the full inventory denominator even when the chart shows Top 1", async (t) => { const { ask, proposals, vehicles } = fixture(t); vehicles.find((vehicle) => vehicle.id === "hz-inventory")!.status = "Abnormal"; const stock = plan({ topN: 1 }); proposals.push({ kind: "query", plan: stock, queries: [part("stock-share", "华东库存分布与子集占比", stock, "chart"), part("pending", "华东待交车清单", plan({ category: "pending", view: "list" }), "list")] }); const result = await ask({ message: "华东库存最多的城市和待交车清单分别展示,同时给待交车与异动占库存比例。" }); const stockResult = result.sections!.find((section) => section.id === "stock-share")!.result!; assert.equal(stockResult.rows.length, 4); assert.equal(stockResult.groups.length, 1); assert.equal(stockResult.groups[0].count, 3); assert.equal(stockResult.metrics.find((metric) => metric.label === "待交车占库存")?.value, 25); assert.equal(stockResult.metrics.find((metric) => metric.label === "异动占库存")?.value, 25); assert.equal(stockResult.metrics.find((metric) => metric.label === "待交车占库存")?.unit, "%"); }); test("an empty inventory denominator has no invented percentage and does not become a query failure", async (t) => { const { ask, proposals, gaps } = fixture(t); const noMatch = plan({ filters: { city: ["北京市"] } }); proposals.push({ kind: "query", plan: noMatch, queries: [part("empty-inventory", "北京库存子集占比", noMatch, "metrics"), part("empty-pending", "北京待交车清单", { ...noMatch, category: "pending", view: "list" }, "list")] }); const result = await ask({ message: "北京库存占比和待交车列表分别展示。" }); const stockResult = result.sections!.find((section) => section.id === "empty-inventory")!.result!; assert.equal(stockResult.metrics[0].value, 0); assert.ok(stockResult.metrics.every((metric) => !/占库存/.test(metric.label))); assert.match(stockResult.summary, /分母.*0|没有|未找到/); assert.equal(result.outcome, "empty"); assert.equal(result.gap, undefined); assert.equal(gaps.length, 0); }); test("explicitly changing the parent region for composed analyses does not restore an obsolete city filter", async (t) => { const { ask, proposals } = fixture(t); proposals.push({ kind: "query", plan: plan({ filters: { region: ["华东"], city: ["嘉兴市"] } }) }); const previous = await ask({ message: "只看华东嘉兴库存" }); assert.equal(previous.rows.length, 3); const southernInventory = plan({ filters: { region: ["华南"] } }); const southernOperating = plan({ category: "operating", filters: { region: ["华南"] } }); proposals.push({ kind: "query", plan: southernInventory, queries: [ part("southern-inventory", "改看华南库存分布", southernInventory, "chart"), part("southern-operating", "改看华南运营概况", southernOperating, "metrics"), ] }); const changed = await ask({ ...based(previous), message: "改看华南,库存分布和运营概况分开展示。" }); const inventoryResult = changed.sections!.find((section) => section.id === "southern-inventory")!.result!; assert.deepEqual(inventoryResult.plan.filters, { region: ["华南"] }); assert.deepEqual(ids(inventoryResult), ["south-inventory"]); assert.ok(changed.sections!.every((section) => !section.result?.plan.filters.city)); }); for (const permissionLevel of ["personal", "department"] as const) { test(`${permissionLevel} can analyze public stock but flow, comparisons, actions and exports retain original scope`, async (t) => { const { app, ask, proposals } = fixture(t, permissionLevel); const compared = { ...flow(), plan: { ...flow().plan, comparePrevious: true } }; proposals.push({ kind: "query", plan: plan(), queries: [inventory(), compared] }); const result = await ask({ message: "库存和交退换趋势分别展示,并对比上期" }); const stock = result.sections!.find(section => section.id === "inventory")!.result!; const business = result.sections!.find(section => section.id === "flow")!.result!; assert.ok(ids(stock).includes("other-owner"), "Other departments' stock is public"); assert.deepEqual(ids(business), ["event-valid"], "Public stock does not expose other owners' historical events"); assert.equal(business.comparison![0].current, 1); assert.equal(business.comparison![0].previous, 1); const day = await ask({ ...based(business), action: { type: "day", date: business.plan.start! } }); assert.deepEqual(ids(day), ["event-valid"]); const exported = await app.request("/ai/export", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sessionId: business.sessionId, resultId: business.resultId, subject: company }), }); assert.equal(exported.status, 200); const csv = await exported.text(); assert.match(csv, /合成-jx-inventory/); assert.doesNotMatch(csv, /合成-other-owner|合成-other-company/); }); }