Files
ln-bi/src/server/routes/asset-assistant/model.test.ts
T

205 lines
7.6 KiB
TypeScript

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 from "node:test";
import { Hono } from "hono";
import { assetResult, validatePlan, visibleVehicles } from "./model.js";
import router from "./index.js";
import { vehicleRepository } from "../vehicles/repository.js";
import type { Vehicle } from "../../types.js";
import type { AuthUser } from "../../auth/types.js";
const user: AuthUser = {
userId: "u1",
userName: "测试",
loginName: "test",
depName: "甲部门",
depCode: "A",
permissionLevel: "personal",
roles: [],
};
const plan = {
kind: "assets",
category: "inventory",
groupBy: "region",
view: "chart",
filters: {},
};
const vehicles = ["Inventory", "Abnormal", "Pending", "Operating"].map(
(status, index) =>
({
id: String(index),
status,
operationStatus: index === 3 ? "1" : "3",
managerId: index === 1 ? "u2" : "u1",
departmentName: "甲部门",
subjectOrg: index === 2 ? "乙公司" : "甲公司",
province: "浙江省",
city: "嘉兴市",
plateNumber: `测试${index}`,
type: "4.5T",
model: "测试型号",
}) as Vehicle,
);
test("public inventory and company filters apply before aggregation and detail generation", () => {
const visible = visibleVehicles(vehicles, user, "甲公司");
const result = assetResult(visible, validatePlan(plan));
assert.equal(result.metrics[0].value, 2);
assert.equal(result.groups[0].count, result.rows.length);
assert.deepEqual(
result.rows.map((v) => v.id),
["0", "1"],
);
});
test("Shanghai counts include districts and preserve permission and company scope", () => {
const input = vehicles.map((v, index) => ({ ...v, province: "上海市", city: ["松江区", "嘉定区", "奉贤区", "上海市"][index] }));
const query = validatePlan({ ...plan, category: "all", groupBy: "city", filters: { city: ["上海市"] } });
const all = assetResult(input, query);
assert.equal(all.metrics[0].value, 4);
assert.equal(all.rows.length, 4);
assert.equal(all.groups.length, 1);
assert.equal(all.groups[0].count, 4);
const scoped = assetResult(visibleVehicles(input, user, "甲公司"), query);
assert.equal(scoped.metrics[0].value, 3);
assert.deepEqual(scoped.rows.map(v => v.id).sort(), ["0", "1", "3"]);
assert.ok(scoped.rows.every(v => v.city === "上海市"));
});
test("inventory includes abnormal and pending; group totals reconcile with detail", () => {
const result = assetResult(vehicles, validatePlan(plan));
assert.equal(result.rows.length, 3);
assert.equal(
result.groups.reduce((n, g) => n + g.count, 0),
3,
);
assert.equal(result.metrics[1].value, 3);
});
test("unsafe or unsupported query plans, impossible dates and long ranges are rejected", () => {
assert.throws(() => validatePlan({ ...plan, filters: { sql: "SELECT *" } }));
assert.throws(() => validatePlan({ ...plan, category: "delete" }));
assert.throws(() => validatePlan({ ...plan, start: "2026-01-01" }));
assert.throws(() =>
validatePlan({
...plan,
kind: "flow",
start: "2026-02-30",
end: "2026-03-05",
}),
);
assert.throws(() =>
validatePlan({
...plan,
kind: "flow",
start: "2026-01-01",
end: "2026-09-01",
}),
);
});
test("assistant rejects unauthenticated direct calls before contacting a model", async () => {
const response = await router.request("/ask", { method: "POST", body: "{}" });
assert.equal(response.status, 401);
});
test("presentation receives authorized aggregates while vehicle rows stay in the internal response", async (t) => {
const oldKey = process.env.DEEPSEEK_API_KEY;
process.env.DEEPSEEK_API_KEY = "test-only";
const oldEnabled = process.env.ASSET_AI_EXTERNAL_ENABLED;
process.env.ASSET_AI_EXTERNAL_ENABLED = "1";
t.after(() => {
if (oldEnabled === undefined) delete process.env.ASSET_AI_EXTERNAL_ENABLED;
else process.env.ASSET_AI_EXTERNAL_ENABLED = oldEnabled;
if (oldKey === undefined) delete process.env.DEEPSEEK_API_KEY;
else process.env.DEEPSEEK_API_KEY = oldKey;
});
t.mock.method(vehicleRepository, "getVehicles", async () => vehicles);
const prompts: string[] = [];
t.mock.method(
globalThis,
"fetch",
async (_url: unknown, init: RequestInit) => {
prompts.push(String(init.body));
return new Response(
JSON.stringify({
choices: [
{
message: {
content:
prompts.length === 1
? JSON.stringify({
kind: "query",
plan,
})
: JSON.stringify({results:[{id:"main",blocks:[{id:"summary",kind:"summary"}],interactions:[]}]}),
},
finish_reason: "stop",
},
],
}),
{ status: 200 },
);
},
);
const app = new Hono<{ Variables: { user: AuthUser } }>();
app.use("*", async (c, next) => {
c.set("user", user);
await next();
});
app.route("/assistant", router);
const response = await app.request("/assistant/ask", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: "统计库存" }),
});
const events = (await response.text())
.split("\n")
.filter((s) => s.startsWith("data:"))
.map((s) => JSON.parse(s.slice(5)));
const result = events.find((e) => e.type === "result").result;
assert.deepEqual(
result.rows.map((r: { id: string }) => r.id),
["0", "1", "2"],
);
assert.equal(prompts.length, 2);
const presentationInput = JSON.parse(JSON.parse(prompts[1]).messages[1].content);
assert.equal(presentationInput.stage, "presentation");
assert.equal(presentationInput.results[0].metrics[0].value, 3);
assert.equal(presentationInput.results[0].rows, undefined);
assert.ok(
prompts.every(
(p) =>
!p.includes("测试0") &&
!p.includes("测试2") &&
!p.includes("plateNumber"),
),
);
assert.equal(events.filter((e) => e.type === "step").length, 4);
});
test("inventory subsets do not inherit parent inventory metric cards", () => {
for (const category of ["abnormal", "pending", "available", "awaiting"] as const) {
const r = assetResult(vehicles, {kind:"assets", category, groupBy:"none", view:"chart", filters:{}});
assert.equal(r.metrics.length, 1, category);
assert.doesNotMatch(r.summary, /其中异动.*待交车/);
}
});
test("personal and department scopes include all current stock subsets but keep operating vehicles restricted", () => {
const stock = vehicles.slice(0, 3).map(v => ({ ...v, subjectOrg: "甲公司", managerId: "other", departmentName: "乙部门" }));
const ownOperating = vehicles[3];
const foreignOperating = { ...ownOperating, id: "foreign-operating", managerId: "other", departmentName: "乙部门" };
const input = [...stock, ownOperating, foreignOperating];
for (const permissionLevel of ["personal", "department"] as const) {
const visible = visibleVehicles(input, { ...user, permissionLevel }, "甲公司");
assert.deepEqual(visible.map(v => v.id), ["0", "1", "2", "3"]);
for (const [category, expected] of [["inventory", ["0", "1", "2"]], ["abnormal", ["1"]], ["pending", ["2"]], ["operating", ["3"]]] as const) {
const result = assetResult(visible, validatePlan({ ...plan, category }));
assert.deepEqual(result.rows.map(v => v.id), expected);
}
}
});