fix: keep submission previews user-readable
This commit is contained in:
+4
-1
@@ -249,6 +249,7 @@ test("MCP approval_login returns both portable text and a standard resource link
|
||||
assert.match(instructions, /审批引用.*调用容量.*内部分批.*不得向用户解释/);
|
||||
assert.match(instructions, /提单草稿仅可保存在 Agent 所在机器.*不得上传网关.*实时模板 Schema/s);
|
||||
assert.match(instructions, /所有选择型字段只向用户展示业务名称.*optionSource.*label.*enumLabels.*城市码.*组织码.*工号.*client_only/s);
|
||||
assert.match(instructions, /用户选定label.*values.*code.*不能填label.*工具调用内部/s);
|
||||
assert.doesNotMatch(instructions, /最多.*20|selectionId|approvalId|targetId/);
|
||||
const history = tools.tools.find((tool) => tool.name === "approval_list_history")!;
|
||||
assert.equal((history.inputSchema as any).properties.startDate.default, "");
|
||||
@@ -258,7 +259,9 @@ test("MCP approval_login returns both portable text and a standard resource link
|
||||
assert.match(batch.description ?? "", /Schema容量.*不向用户解释.*任一无效则零写入/s);
|
||||
const options = tools.tools.find((tool) => tool.name === "approval_get_submission_options")!;
|
||||
assert.deepEqual((options.inputSchema as any).properties.kind.enum, ["department", "city", "expense_type"]);
|
||||
assert.match(options.description ?? "", /所有选项只向用户展示label.*code仅供内部提交/s);
|
||||
assert.match(options.description ?? "", /所有选项只向用户展示label.*values.*code.*不能填label.*工具内部/s);
|
||||
const submissionSchema = tools.tools.find((tool) => tool.name === "approval_get_submission_schema")!;
|
||||
assert.match(submissionSchema.description ?? "", /minimalExample.*尖括号必须替换/);
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
|
||||
@@ -52,7 +52,8 @@ test("MCP initialization teaches the workflow while tools/list stays compact", a
|
||||
assert.match(client.getInstructions() ?? "", /恢复时.*实时读取 Schema/s);
|
||||
assert.match(client.getInstructions() ?? "", /自动清理超过 90 天/s);
|
||||
assert.match(client.getInstructions() ?? "", /outcome_uncertain.*冻结.*禁止自动重试/s);
|
||||
assert.match(client.getInstructions() ?? "", /所有选择型字段只向用户展示业务名称.*optionSource.*options\/enumLabels.*内部映射正式值/s);
|
||||
assert.match(client.getInstructions() ?? "", /所有选择型字段只向用户展示业务名称.*optionSource.*options\/enumLabels.*values.*正式值/s);
|
||||
assert.match(client.getInstructions() ?? "", /用户选择 label.*values.*同一选项的 code.*不能把 label.*code 只存在于工具调用内部/s);
|
||||
assert.match(client.getInstructions() ?? "", /绝不向用户展示、解释或索取.*城市码.*组织码.*工号.*档案编号.*数字舱位值.*内部 ID/s);
|
||||
assert.match(client.getInstructions() ?? "", /selectionCapability\.status=client_only.*必填时停止.*可选时.*跳过/s);
|
||||
|
||||
@@ -86,11 +87,13 @@ test("MCP initialization teaches the workflow while tools/list stays compact", a
|
||||
assert.match(descriptions.approval_process_batch ?? "", /Schema容量.*不得转述给用户.*任一无效.*零写入/s);
|
||||
assert.match(descriptions.approval_prepare_submission ?? "", /明确确认|确认/);
|
||||
assert.match(descriptions.approval_get_submission_schema ?? "", /必填字段齐全.*可选字段/);
|
||||
assert.match(descriptions.approval_get_submission_options ?? "", /承担部门.*城市.*费用类型.*只向用户展示label.*内部使用code/s);
|
||||
assert.match(descriptions.approval_get_submission_schema ?? "", /minimalExample.*values形状.*尖括号必须替换/);
|
||||
assert.match(descriptions.approval_get_submission_options ?? "", /承担部门.*城市.*费用类型.*只向用户展示label.*values.*code.*不能填label/s);
|
||||
const optionSchema = tools.tools.find((tool) => tool.name === "approval_get_submission_options")?.inputSchema as any;
|
||||
assert.deepEqual(optionSchema.properties.kind.enum, ["department", "city", "expense_type"]);
|
||||
assert.match(descriptions.approval_prepare_submission ?? "", /继续询问/);
|
||||
assert.match(descriptions.approval_prepare_submission ?? "", /查重.*allowDuplicateInvoices/s);
|
||||
assert.match(descriptions.approval_prepare_submission ?? "", /values.*code.*不能直接填label.*预览.*业务名称/s);
|
||||
const prepareSchema = tools.tools.find((tool) => tool.name === "approval_prepare_submission")?.inputSchema as any;
|
||||
assert.equal(prepareSchema.properties.allowDuplicateInvoices.default, false);
|
||||
assert.match(descriptions.approval_list_submission_templates ?? "", /出差申请.*外出.*差旅费用报销.*业务招待费用报销/);
|
||||
|
||||
@@ -1108,6 +1108,164 @@ test("Xft application templates support API submission with live post-write stat
|
||||
assert.equal(travelPrepared.status, "confirmation_required");
|
||||
});
|
||||
|
||||
test("Xft preparation preview resolves every selection to user-readable business names", async () => {
|
||||
const template: XftSubmissionTemplate = {
|
||||
eventSeqId: "event-travel", eventNumber: "XFT-TRAVEL", eventName: "差旅报销单",
|
||||
eventType: "TRAVEL_REIM", kind: "travel_reimburse",
|
||||
};
|
||||
const fields: XftSubmissionField[] = [
|
||||
{
|
||||
fieldNumber: "C-TOPIC", name: "申请事项", type: "SELECT", required: true,
|
||||
options: [{ code: "PROCUREMENT", label: "采购事项" }],
|
||||
selectionCapability: { status: "supported", userInput: "label", submitValue: "code", exposeInternalValue: false },
|
||||
},
|
||||
{
|
||||
fieldNumber: "C-TABLE", name: "补充明细", type: "TABLE", required: true,
|
||||
itemFields: [{
|
||||
titleCode: "T-TYPE", label: "明细类型", type: "SELECT", required: true,
|
||||
options: [{ code: "MARKETING", label: "市场活动" }],
|
||||
selectionCapability: { status: "supported", userInput: "label", submitValue: "code", exposeInternalValue: false },
|
||||
}],
|
||||
},
|
||||
];
|
||||
const standardFields = [
|
||||
{ key: "reimAmount", label: "报销总金额", type: "decimal" },
|
||||
{ key: "reimAmountCurrencyCode", label: "报销币种", type: "string", enumLabels: { CNY: "人民币" } },
|
||||
{
|
||||
key: "expenseList", label: "费用明细", type: "array", itemFields: [
|
||||
{ key: "expTypeCode", label: "费用类型", type: "string", optionSource: { kind: "expense_type" } },
|
||||
{ key: "currencyCode", label: "费用币种", type: "string", enumLabels: { CNY: "人民币" } },
|
||||
{ key: "originalCurrencyReimAmt", label: "费用金额", type: "decimal" },
|
||||
{ key: "payCityCode", label: "消费城市", type: "string", optionSource: { kind: "city" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "businessTrip", label: "差旅行程", type: "object", fields: [{
|
||||
key: "businessTripDetails", label: "行程明细", type: "array", itemFields: [
|
||||
{ key: "departCityCode", label: "出发城市", type: "string", optionSource: { kind: "city" } },
|
||||
{ key: "destinationCityCode", label: "目的城市", type: "string", optionSource: { kind: "city" } },
|
||||
{ key: "beginTime", label: "出发时间", type: "datetime" },
|
||||
{ key: "endTime", label: "结束时间", type: "datetime" },
|
||||
{ key: "travelMode", label: "出行方式", type: "comma_separated_enum", multiple: true, enumLabels: { AirPlane: "飞机", Train: "火车" } },
|
||||
{ key: "roundTrip", label: "是否往返", type: "string", enumLabels: { Y: "往返", N: "单程" } },
|
||||
],
|
||||
}],
|
||||
},
|
||||
{ key: "customFieldValues", userVisible: false, type: "array" },
|
||||
];
|
||||
const optionCalls: Array<{ kind: string; query: string }> = [];
|
||||
const xft = {
|
||||
async listSubmissionTemplates() { return [template]; },
|
||||
async getSubmissionSchema() {
|
||||
return { template, fields, inputContract: { values: { standardFields } }, diagnostics: {} };
|
||||
},
|
||||
async getSubmissionOptions(_user: unknown, _template: unknown, kind: string, query: string) {
|
||||
optionCalls.push({ kind, query });
|
||||
const labels: Record<string, string> = {
|
||||
"440300": "深圳市", "310100": "上海市", MEAL: "餐饮费",
|
||||
};
|
||||
return {
|
||||
kind,
|
||||
items: labels[query] ? [{ code: query, label: labels[query] }] : [],
|
||||
diagnostics: {},
|
||||
};
|
||||
},
|
||||
async validateSubmissionIdentity() { return { validated: true, diagnostics: {} }; },
|
||||
};
|
||||
const service = new UnifiedApprovalService(config(), {} as ApprovalService, xft as never);
|
||||
const user = { userId: "user-1" };
|
||||
const templateId = String((await service.listSubmissionTemplates(user, "xft", 10)).items[0]?.templateId);
|
||||
const prepared = await service.prepareSubmission(user, {
|
||||
templateId,
|
||||
values: {
|
||||
reimAmount: "100.00",
|
||||
reimAmountCurrencyCode: "CNY",
|
||||
expenseList: [{
|
||||
expTypeCode: "MEAL", currencyCode: "CNY", originalCurrencyReimAmt: "100.00", payCityCode: "440300",
|
||||
}],
|
||||
businessTrip: { businessTripDetails: [{
|
||||
departCityCode: "440300", destinationCityCode: "310100",
|
||||
beginTime: "2026-08-20 09:00", endTime: "2026-08-21 18:00",
|
||||
travelMode: "AirPlane,Train", roundTrip: "Y",
|
||||
}] },
|
||||
customFieldValues: [
|
||||
{ fieldNumber: "C-TOPIC", fieldValue: "PROCUREMENT" },
|
||||
{ fieldNumber: "C-TABLE", fieldValue: JSON.stringify([{ sequence: 1, columnValues: [{ titleCode: "T-TYPE", value: "MARKETING" }] }]) },
|
||||
],
|
||||
},
|
||||
});
|
||||
assert.equal(prepared.status, "confirmation_required");
|
||||
const summary = JSON.stringify(prepared.preview.formSummary);
|
||||
for (const label of ["深圳市", "上海市", "餐饮费", "人民币", "飞机", "火车", "往返", "采购事项", "市场活动"]) {
|
||||
assert.match(summary, new RegExp(label));
|
||||
}
|
||||
for (const internal of ["440300", "310100", "MEAL", "C-TOPIC", "PROCUREMENT", "CNY", "T-TYPE", "MARKETING"]) {
|
||||
assert.doesNotMatch(summary, new RegExp(internal));
|
||||
}
|
||||
assert.deepEqual(optionCalls, [
|
||||
{ kind: "expense_type", query: "MEAL" },
|
||||
{ kind: "city", query: "440300" },
|
||||
{ kind: "city", query: "310100" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("Xft preparation fails before confirmation when a selected code has no readable mapping", async () => {
|
||||
const template: XftSubmissionTemplate = {
|
||||
eventSeqId: "event-expense", eventNumber: "XFT-EXPENSE", eventName: "费用申请单",
|
||||
eventType: "EXPENSE_APPLY", kind: "expense_apply",
|
||||
};
|
||||
const xft = {
|
||||
async listSubmissionTemplates() { return [template]; },
|
||||
async getSubmissionSchema() {
|
||||
return {
|
||||
template,
|
||||
fields: [],
|
||||
inputContract: { values: { standardFields: [{
|
||||
key: "applyExpenses", label: "事前申请费用列表", type: "array", itemFields: [
|
||||
{ key: "expenseNumber", label: "费用类型", type: "string", optionSource: { kind: "expense_type" } },
|
||||
{ key: "amount", label: "申请金额", type: "decimal" },
|
||||
],
|
||||
}] } },
|
||||
diagnostics: {},
|
||||
};
|
||||
},
|
||||
async getSubmissionOptions() { return { kind: "expense_type", items: [], diagnostics: {} }; },
|
||||
async validateSubmissionIdentity() { return { validated: true, diagnostics: {} }; },
|
||||
};
|
||||
const service = new UnifiedApprovalService(config(), {} as ApprovalService, xft as never);
|
||||
const user = { userId: "user-1" };
|
||||
const templateId = String((await service.listSubmissionTemplates(user, "xft", 10)).items[0]?.templateId);
|
||||
await assert.rejects(
|
||||
service.prepareSubmission(user, {
|
||||
templateId, values: { applyExpenses: [{ expenseNumber: "UNKNOWN", amount: "10.00" }] },
|
||||
}),
|
||||
/无法映射为业务名称.*重新查询可用名称/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Xft use-car application is explicitly client-only and cannot be prepared", async () => {
|
||||
const template: XftSubmissionTemplate = {
|
||||
eventSeqId: "event-car", eventNumber: "XFT-CAR", eventName: "用车申请单",
|
||||
eventType: "USE_CAR_APPLY", kind: "use_car_apply",
|
||||
};
|
||||
const xft = {
|
||||
async listSubmissionTemplates() { return [template]; },
|
||||
async getSubmissionSchema() {
|
||||
return { template, fields: [], inputContract: { values: { standardFields: [] } }, diagnostics: {} };
|
||||
},
|
||||
};
|
||||
const service = new UnifiedApprovalService(config(), {} as ApprovalService, xft as never);
|
||||
const user = { userId: "user-1" };
|
||||
const templateId = String((await service.listSubmissionTemplates(user, "xft", 10)).items[0]?.templateId);
|
||||
const schema = await service.getSubmissionSchema(user, templateId);
|
||||
assert.equal(schema.submissionCapability.status, "client_only");
|
||||
assert.match(String(schema.submissionCapability.reason), /用车信息对象.*未提供可验证/);
|
||||
await assert.rejects(
|
||||
service.prepareSubmission(user, { templateId, values: { useCarInfo: { purpose: "客户拜访" } } }),
|
||||
/用车申请.*薪福通客户端/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Xft preparation reports standard and live custom required fields together", async () => {
|
||||
const template: XftSubmissionTemplate = {
|
||||
eventSeqId: "1", eventNumber: "XFT-E", eventName: "招待费用申请单",
|
||||
|
||||
@@ -368,6 +368,9 @@ test("Xft submission discovers live templates and derives protected create field
|
||||
assert.equal(schema.fields[0]?.fieldNumber, "C1");
|
||||
assert.equal(schema.fields.length, 1);
|
||||
const standardFields = (schema.inputContract.values as Record<string, unknown>).standardFields as Array<Record<string, unknown>>;
|
||||
const minimalExample = (schema.inputContract.values as Record<string, unknown>).minimalExample as Record<string, unknown>;
|
||||
assert.deepEqual(Object.keys(minimalExample), ["reimAmount", "reimAmountCurrencyCode", "expenseList"]);
|
||||
assert.match(JSON.stringify(minimalExample), /报销总金额.*费用类型选项.*本笔费用金额/);
|
||||
const expenseList = standardFields.find((field) => field.key === "expenseList");
|
||||
const expenseItemFields = expenseList?.itemFields as Array<Record<string, unknown>>;
|
||||
assert.deepEqual(expenseItemFields.filter((field) => field.required).map((field) => field.key), [
|
||||
@@ -509,6 +512,11 @@ test("all formal Xft city and enum choices expose readable labels while unresolv
|
||||
const templates = await client.listSubmissionTemplates({ userId: "ding-u1" }, 20);
|
||||
const schemas = await Promise.all(templates.map((template) => client.getSubmissionSchema({ userId: "ding-u1" }, template)));
|
||||
const combined = schemas.map((schema) => schema.inputContract);
|
||||
for (const schema of schemas) {
|
||||
const values = schema.inputContract.values as Record<string, unknown>;
|
||||
assert.ok(values.minimalExample && typeof values.minimalExample === "object");
|
||||
assert.match(JSON.stringify(values.minimalExample), /</);
|
||||
}
|
||||
for (const key of ["payCityCode", "entertainmentCityCode", "departCityCode", "destinationCityCode", "departureCode", "destinationCode"]) {
|
||||
const matches = findRecordsByKey(combined, key);
|
||||
assert.ok(matches.length > 0, `${key} should exist`);
|
||||
|
||||
Reference in New Issue
Block a user