2026-05-22 08:58:59 +08:00
|
|
|
|
import assert from 'node:assert/strict'
|
2026-05-22 16:00:19 +08:00
|
|
|
|
import { readFileSync } from 'node:fs'
|
2026-05-22 08:58:59 +08:00
|
|
|
|
import test from 'node:test'
|
2026-05-22 16:00:19 +08:00
|
|
|
|
import { fileURLToPath } from 'node:url'
|
2026-05-22 08:58:59 +08:00
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
|
ATTACHMENT_ASSOCIATION_CONFIRM_HREF,
|
2026-06-18 22:12:24 +08:00
|
|
|
|
attachReceiptFolderIdsToFiles,
|
2026-05-22 16:00:19 +08:00
|
|
|
|
buildAttachmentAssociationConfirmationMessage,
|
|
|
|
|
|
buildOcrFilePreviews,
|
2026-05-22 23:47:28 +08:00
|
|
|
|
buildReviewFilePreviewsFromReviewPayload,
|
|
|
|
|
|
buildUnsavedDraftAttachmentConfirmationMessage,
|
2026-06-18 22:12:24 +08:00
|
|
|
|
collectReceiptFiles,
|
2026-05-22 23:47:28 +08:00
|
|
|
|
filterPersistableFilePreviews,
|
2026-06-18 22:12:24 +08:00
|
|
|
|
mergeFilePreviews,
|
|
|
|
|
|
normalizeOcrDocuments
|
2026-05-22 08:58:59 +08:00
|
|
|
|
} from '../src/views/scripts/travelReimbursementAttachmentModel.js'
|
2026-05-22 16:00:19 +08:00
|
|
|
|
import {
|
|
|
|
|
|
buildDraftAssociationQueryPayload,
|
|
|
|
|
|
buildExpenseQueryHint,
|
|
|
|
|
|
EXPENSE_CENTER_HREF,
|
|
|
|
|
|
normalizeExpenseQueryPayload
|
|
|
|
|
|
} from '../src/views/scripts/travelReimbursementExpenseQueryModel.js'
|
|
|
|
|
|
import { renderMarkdown } from '../src/utils/markdown.js'
|
2026-05-22 08:58:59 +08:00
|
|
|
|
|
|
|
|
|
|
test('attachment association prompt prints recognized receipt details before confirmation link', () => {
|
|
|
|
|
|
const message = buildAttachmentAssociationConfirmationMessage({
|
|
|
|
|
|
claimNo: 'EXP-202605-001',
|
|
|
|
|
|
fileNames: ['train-ticket.pdf'],
|
|
|
|
|
|
ocrDocuments: [
|
|
|
|
|
|
{
|
|
|
|
|
|
filename: 'train-ticket.pdf',
|
|
|
|
|
|
document_type: 'train_ticket',
|
|
|
|
|
|
scene_label: '差旅票据',
|
|
|
|
|
|
summary: '铁路电子客票 武汉-上海 票价 354 元',
|
|
|
|
|
|
document_fields: [
|
|
|
|
|
|
{ key: 'route', label: '行程', value: '武汉-上海' },
|
|
|
|
|
|
{ key: 'amount', label: '票价', value: '354.00' },
|
|
|
|
|
|
{ key: 'date', label: '乘车日期', value: '2026-02-20' }
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
assert.match(message, /已识别附件信息:/)
|
2026-05-22 16:00:19 +08:00
|
|
|
|
assert.match(message, /> \*\*附件 1:train-ticket\.pdf\*\*/)
|
2026-05-22 08:58:59 +08:00
|
|
|
|
assert.match(message, /附件类型:差旅票据/)
|
|
|
|
|
|
assert.match(message, /行程:武汉-上海/)
|
|
|
|
|
|
assert.match(message, /票价:354.00/)
|
|
|
|
|
|
assert.match(message, /草稿单号:EXP-202605-001/)
|
2026-05-22 16:00:19 +08:00
|
|
|
|
assert.match(message, new RegExp(`\\n\\n\\n如果 \\*\\*\\[确认\\]\\(${ATTACHMENT_ASSOCIATION_CONFIRM_HREF}\\)\\*\\* 该信息`))
|
|
|
|
|
|
|
|
|
|
|
|
const rendered = renderMarkdown(message)
|
|
|
|
|
|
assert.match(rendered, /<blockquote class="markdown-attachment-card">/)
|
|
|
|
|
|
const questionIndex = rendered.indexOf('请问是否确定将票据信息归集到单据')
|
|
|
|
|
|
const attachmentCardCloseIndex = rendered.indexOf('</blockquote>')
|
|
|
|
|
|
assert.ok(attachmentCardCloseIndex > -1 && questionIndex > attachmentCardCloseIndex)
|
|
|
|
|
|
const attachmentCardHtml = rendered.slice(
|
|
|
|
|
|
rendered.indexOf('<blockquote class="markdown-attachment-card">'),
|
|
|
|
|
|
attachmentCardCloseIndex
|
|
|
|
|
|
)
|
|
|
|
|
|
assert.doesNotMatch(attachmentCardHtml, /请问是否确定将票据信息归集到单据/)
|
|
|
|
|
|
assert.match(rendered, /<p class="markdown-action-paragraph">/)
|
|
|
|
|
|
assert.match(rendered, /<strong><a href="#confirm-attachment-association" class="markdown-action-link markdown-action-link-confirm">确认<\/a><\/strong>/)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('multiple recognized attachments render as separated attachment cards', () => {
|
|
|
|
|
|
const message = buildAttachmentAssociationConfirmationMessage({
|
|
|
|
|
|
claimNo: 'EXP-202605-001',
|
|
|
|
|
|
ocrDocuments: [
|
|
|
|
|
|
{
|
|
|
|
|
|
filename: '2月20 武汉-上海.pdf',
|
|
|
|
|
|
document_type: 'train_ticket',
|
|
|
|
|
|
document_type_label: '火车/高铁票',
|
|
|
|
|
|
document_fields: [
|
|
|
|
|
|
{ key: 'amount', label: '金额', value: '354元' },
|
|
|
|
|
|
{ key: 'route', label: '行程', value: '武汉-上海' }
|
|
|
|
|
|
]
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
filename: '2月23 上海-武汉.pdf',
|
|
|
|
|
|
document_type: 'train_ticket',
|
|
|
|
|
|
document_type_label: '火车/高铁票',
|
|
|
|
|
|
document_fields: [
|
|
|
|
|
|
{ key: 'amount', label: '金额', value: '354元' },
|
|
|
|
|
|
{ key: 'route', label: '行程', value: '上海-武汉' }
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
})
|
|
|
|
|
|
const rendered = renderMarkdown(message)
|
|
|
|
|
|
|
|
|
|
|
|
assert.equal((rendered.match(/class="markdown-attachment-card"/g) || []).length, 2)
|
|
|
|
|
|
assert.match(rendered, /<strong>附件 1:2月20 武汉-上海\.pdf<\/strong>/)
|
|
|
|
|
|
assert.match(rendered, /<strong>附件 2:2月23 上海-武汉\.pdf<\/strong>/)
|
|
|
|
|
|
assert.match(rendered, /本次待归集附件:2 份/)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('attachment upload association uses conversation selection instead of legacy modal', () => {
|
|
|
|
|
|
const viewSource = readFileSync(
|
|
|
|
|
|
fileURLToPath(new URL('../src/views/TravelReimbursementCreateView.vue', import.meta.url)),
|
|
|
|
|
|
'utf8'
|
|
|
|
|
|
)
|
|
|
|
|
|
const submitComposerSource = readFileSync(
|
|
|
|
|
|
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
|
|
|
|
|
|
'utf8'
|
|
|
|
|
|
)
|
2026-06-22 11:58:53 +08:00
|
|
|
|
const submitAttachmentFlowSource = readFileSync(
|
|
|
|
|
|
fileURLToPath(new URL('../src/views/scripts/travelReimbursementSubmitAttachmentFlow.js', import.meta.url)),
|
|
|
|
|
|
'utf8'
|
|
|
|
|
|
)
|
|
|
|
|
|
const submitDraftPreflightSource = readFileSync(
|
|
|
|
|
|
fileURLToPath(new URL('../src/views/scripts/travelReimbursementSubmitDraftPreflight.js', import.meta.url)),
|
|
|
|
|
|
'utf8'
|
|
|
|
|
|
)
|
2026-05-22 23:47:28 +08:00
|
|
|
|
const flowSource = readFileSync(
|
|
|
|
|
|
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementFlow.js', import.meta.url)),
|
|
|
|
|
|
'utf8'
|
|
|
|
|
|
)
|
2026-06-22 15:56:06 +08:00
|
|
|
|
const flowToolSource = readFileSync(
|
|
|
|
|
|
fileURLToPath(new URL('../src/views/scripts/travelReimbursementFlowToolModel.js', import.meta.url)),
|
|
|
|
|
|
'utf8'
|
|
|
|
|
|
)
|
2026-05-22 23:47:28 +08:00
|
|
|
|
const conversationSource = readFileSync(
|
2026-06-22 15:56:06 +08:00
|
|
|
|
fileURLToPath(new URL('../src/views/scripts/travelReimbursementConversationSessionModel.js', import.meta.url)),
|
2026-05-22 23:47:28 +08:00
|
|
|
|
'utf8'
|
|
|
|
|
|
)
|
2026-05-22 16:00:19 +08:00
|
|
|
|
|
|
|
|
|
|
assert.doesNotMatch(viewSource, /检测到你已有单据事件|uploadDecisionDialogOpen|continueExistingUpload|createNewUploadDocument/)
|
|
|
|
|
|
assert.doesNotMatch(submitComposerSource, /uploadDecisionDialogOpen|hasExistingDocumentEvent|skipUploadDecisionPrompt/)
|
|
|
|
|
|
assert.doesNotMatch(submitComposerSource, /查询可关联草稿失败,已继续按新单据识别/)
|
|
|
|
|
|
assert.match(
|
2026-06-22 11:58:53 +08:00
|
|
|
|
submitDraftPreflightSource,
|
2026-05-22 16:00:19 +08:00
|
|
|
|
/const claims = await fetchExpenseClaims\(\)[\s\S]*const queryPayload = buildDraftAssociationQueryPayload\(claims\)[\s\S]*meta: \['等待选择关联单据'\][\s\S]*queryPayload/
|
|
|
|
|
|
)
|
2026-06-22 11:58:53 +08:00
|
|
|
|
assert.match(submitDraftPreflightSource, /meta: \['单据查询失败'\][\s\S]*return \{ handled: true, value: null \}/)
|
2026-05-22 16:00:19 +08:00
|
|
|
|
assert.match(
|
2026-06-22 11:58:53 +08:00
|
|
|
|
submitDraftPreflightSource,
|
2026-05-22 16:00:19 +08:00
|
|
|
|
/files\.length[\s\S]*!resolvedUploadDisposition[\s\S]*!options\.skipDraftAssociationPrompt[\s\S]*!reviewAction/
|
|
|
|
|
|
)
|
2026-06-22 11:58:53 +08:00
|
|
|
|
assert.match(submitDraftPreflightSource, /mode:\s*'save_then_associate'/)
|
|
|
|
|
|
assert.match(submitAttachmentFlowSource, /review_action:\s*'save_draft'[\s\S]*review_action:\s*'link_to_existing_draft'/)
|
|
|
|
|
|
assert.match(submitAttachmentFlowSource, /appendToCurrentFlow:\s*true/)
|
2026-05-22 23:47:28 +08:00
|
|
|
|
assert.match(submitComposerSource, /const appendToCurrentFlow = Boolean\(options\.appendToCurrentFlow\)/)
|
|
|
|
|
|
assert.match(submitComposerSource, /if \(!appendToCurrentFlow\) \{\s*resetFlowRun\(\)\s*\} else \{\s*clearFlowSimulationTimers\(\)/)
|
|
|
|
|
|
assert.match(flowSource, /link_to_existing_draft:\s*\{[\s\S]*key:\s*'attachment-association'/)
|
2026-06-22 15:56:06 +08:00
|
|
|
|
assert.match(flowToolSource, /responseMessage\.includes\('关联'\)[\s\S]*key:\s*'attachment-association'/)
|
2026-06-02 14:01:51 +08:00
|
|
|
|
assert.match(flowSource, /'draft-risk-review'/)
|
|
|
|
|
|
assert.match(flowSource, /草稿风险识别/)
|
2026-05-22 23:47:28 +08:00
|
|
|
|
assert.match(conversationSource, /'attachment-association':\s*\{[\s\S]*title:\s*'票据关联草稿'/)
|
2026-06-02 14:01:51 +08:00
|
|
|
|
assert.match(conversationSource, /'draft-risk-review':\s*\{[\s\S]*title:\s*'草稿风险识别'/)
|
2026-05-22 23:47:28 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('unsaved review attachment prompt asks for explicit rich-text confirmation', () => {
|
|
|
|
|
|
const message = buildUnsavedDraftAttachmentConfirmationMessage({
|
|
|
|
|
|
fileNames: ['taxi.pdf']
|
|
|
|
|
|
})
|
|
|
|
|
|
const rendered = renderMarkdown(message)
|
|
|
|
|
|
|
|
|
|
|
|
assert.match(message, /当前这笔报销信息还没有保存为草稿/)
|
|
|
|
|
|
assert.match(message, /本次待归集附件:1 份/)
|
|
|
|
|
|
assert.match(message, new RegExp(`\\*\\*\\[确定\\]\\(${ATTACHMENT_ASSOCIATION_CONFIRM_HREF}\\)\\*\\*`))
|
|
|
|
|
|
assert.match(rendered, /<strong><a href="#confirm-attachment-association" class="markdown-action-link markdown-action-link-confirm">确定<\/a><\/strong>/)
|
2026-05-22 16:00:19 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('OCR preview builders keep hotel receipt image previews when preview kind is omitted', () => {
|
|
|
|
|
|
const dataUrl = 'data:image/png;base64,abc123'
|
|
|
|
|
|
const ocrPreviews = buildOcrFilePreviews({
|
|
|
|
|
|
documents: [
|
|
|
|
|
|
{
|
|
|
|
|
|
filename: 'hotel.png',
|
|
|
|
|
|
preview_data_url: dataUrl
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
})
|
|
|
|
|
|
const reviewPreviews = buildReviewFilePreviewsFromReviewPayload({
|
|
|
|
|
|
document_cards: [
|
|
|
|
|
|
{
|
|
|
|
|
|
filename: 'hotel.png',
|
|
|
|
|
|
preview_url: dataUrl
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
assert.deepEqual(ocrPreviews, [{ filename: 'hotel.png', kind: 'image', url: dataUrl }])
|
|
|
|
|
|
assert.deepEqual(reviewPreviews, [{ filename: 'hotel.png', kind: 'image', url: dataUrl }])
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-06-18 22:12:24 +08:00
|
|
|
|
test('OCR receipt folder ids are kept for final draft attachment association', () => {
|
|
|
|
|
|
const files = [
|
|
|
|
|
|
{ name: 'invoice.png' },
|
|
|
|
|
|
{ name: 'taxi.pdf' }
|
|
|
|
|
|
]
|
|
|
|
|
|
const ocrPayload = {
|
|
|
|
|
|
documents: [
|
|
|
|
|
|
{
|
|
|
|
|
|
filename: 'invoice.png',
|
|
|
|
|
|
receipt_id: 'receipt-1',
|
|
|
|
|
|
receipt_status: 'unlinked',
|
|
|
|
|
|
receipt_preview_url: '/receipt-folder/receipt-1/preview',
|
|
|
|
|
|
receipt_source_url: '/receipt-folder/receipt-1/source'
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
filename: 'taxi.pdf',
|
|
|
|
|
|
receipt_id: 'receipt-2',
|
|
|
|
|
|
receipt_status: 'unlinked',
|
|
|
|
|
|
receipt_preview_url: '/receipt-folder/receipt-2/preview',
|
|
|
|
|
|
receipt_source_url: '/receipt-folder/receipt-2/source'
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const documents = normalizeOcrDocuments(ocrPayload)
|
|
|
|
|
|
|
|
|
|
|
|
assert.equal(documents[0].receipt_id, 'receipt-1')
|
|
|
|
|
|
assert.equal(documents[0].receipt_status, 'unlinked')
|
|
|
|
|
|
assert.equal(documents[0].receipt_preview_url, '/receipt-folder/receipt-1/preview')
|
|
|
|
|
|
assert.equal(documents[0].receipt_source_url, '/receipt-folder/receipt-1/source')
|
|
|
|
|
|
assert.equal(attachReceiptFolderIdsToFiles(files, ocrPayload), 2)
|
|
|
|
|
|
assert.equal(files[0].receiptId, 'receipt-1')
|
|
|
|
|
|
assert.equal(files[1].receiptId, 'receipt-2')
|
|
|
|
|
|
assert.equal(Object.getOwnPropertyDescriptor(files[0], 'receiptId')?.enumerable, false)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-06-21 23:24:09 +08:00
|
|
|
|
test('OCR documents keep full recognized text for backend context', () => {
|
|
|
|
|
|
const longText = [
|
|
|
|
|
|
'增值税电子发票',
|
|
|
|
|
|
'购买方名称:远光软件股份有限公司',
|
|
|
|
|
|
'销售方名称:上海高铁服务有限公司',
|
|
|
|
|
|
'项目名称:客运服务',
|
|
|
|
|
|
'出发地:武汉',
|
|
|
|
|
|
'到达地:上海',
|
|
|
|
|
|
'乘车日期:2026-02-20',
|
|
|
|
|
|
'车次:G1234',
|
|
|
|
|
|
'座位等级:二等座',
|
|
|
|
|
|
'金额:354.00元',
|
|
|
|
|
|
'税额:10.62元',
|
|
|
|
|
|
'发票号码:12345678901234567890',
|
|
|
|
|
|
'开票日期:2026-02-21',
|
|
|
|
|
|
'购买方纳税人识别号:91440400618256625E',
|
|
|
|
|
|
'销售方纳税人识别号:91310000132234123X',
|
|
|
|
|
|
'备注:本票据用于差旅报销,请核对出发城市、到达城市、车次、座位等级、金额、税额和电子客票号。',
|
|
|
|
|
|
'电子客票号:E1234567890'
|
|
|
|
|
|
].join('\n')
|
|
|
|
|
|
|
|
|
|
|
|
assert.ok(longText.length > 240)
|
|
|
|
|
|
|
|
|
|
|
|
const documents = normalizeOcrDocuments({
|
|
|
|
|
|
documents: [
|
|
|
|
|
|
{
|
|
|
|
|
|
filename: 'train-ticket.pdf',
|
|
|
|
|
|
text: longText,
|
|
|
|
|
|
summary: '铁路电子客票 武汉-上海',
|
|
|
|
|
|
document_fields: [
|
|
|
|
|
|
{ key: 'amount', label: '金额', value: '354.00元' },
|
|
|
|
|
|
{ key: 'ticket_no', label: '电子客票号', value: 'E1234567890' }
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
assert.equal(documents[0].text, longText)
|
|
|
|
|
|
assert.match(documents[0].text, /电子客票号:E1234567890/)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-06-18 22:12:24 +08:00
|
|
|
|
test('receipt files are collected through a single OCR persistence entry before draft association', async () => {
|
|
|
|
|
|
const files = [
|
|
|
|
|
|
{ name: 'invoice.png' }
|
|
|
|
|
|
]
|
|
|
|
|
|
let recognizeCallCount = 0
|
|
|
|
|
|
|
|
|
|
|
|
const collected = await collectReceiptFiles({
|
|
|
|
|
|
files,
|
|
|
|
|
|
recognizeOcrFiles: async (inputFiles, options) => {
|
|
|
|
|
|
recognizeCallCount += 1
|
|
|
|
|
|
assert.equal(inputFiles, files)
|
|
|
|
|
|
assert.equal(options.timeoutMs, 90000)
|
|
|
|
|
|
return {
|
|
|
|
|
|
documents: [
|
|
|
|
|
|
{
|
|
|
|
|
|
filename: 'invoice.png',
|
|
|
|
|
|
summary: '发票金额 100 元',
|
|
|
|
|
|
preview_kind: 'image',
|
|
|
|
|
|
preview_data_url: 'data:image/png;base64,abc123',
|
|
|
|
|
|
receipt_id: 'receipt-collect-1',
|
|
|
|
|
|
receipt_status: 'unlinked',
|
|
|
|
|
|
receipt_preview_url: '/receipt-folder/receipt-collect-1/preview',
|
|
|
|
|
|
receipt_source_url: '/receipt-folder/receipt-collect-1/source'
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
assert.equal(recognizeCallCount, 1)
|
|
|
|
|
|
assert.equal(files[0].receiptId, 'receipt-collect-1')
|
|
|
|
|
|
assert.equal(collected.ocrPayload.documents[0].receipt_id, 'receipt-collect-1')
|
|
|
|
|
|
assert.equal(collected.ocrDocuments[0].receipt_id, 'receipt-collect-1')
|
|
|
|
|
|
assert.equal(collected.ocrSummary, 'invoice.png:发票金额 100 元')
|
|
|
|
|
|
assert.deepEqual(collected.ocrFilePreviews, [
|
|
|
|
|
|
{ filename: 'invoice.png', kind: 'image', url: 'data:image/png;base64,abc123' }
|
|
|
|
|
|
])
|
|
|
|
|
|
|
2026-06-22 11:58:53 +08:00
|
|
|
|
const submitRecognitionFlowSource = readFileSync(
|
|
|
|
|
|
fileURLToPath(new URL('../src/views/scripts/travelReimbursementSubmitRecognitionFlow.js', import.meta.url)),
|
2026-06-18 22:12:24 +08:00
|
|
|
|
'utf8'
|
|
|
|
|
|
)
|
2026-06-22 11:58:53 +08:00
|
|
|
|
assert.match(submitRecognitionFlowSource, /collectReceiptFiles\(/)
|
|
|
|
|
|
assert.doesNotMatch(submitRecognitionFlowSource, /recognizeOcrFiles\(files,[\s\S]*attachReceiptFolderIdsToFiles/)
|
|
|
|
|
|
assert.doesNotMatch(submitRecognitionFlowSource, /ocrDocuments = normalizeOcrDocuments\(ocrPayload\)/)
|
|
|
|
|
|
assert.doesNotMatch(submitRecognitionFlowSource, /ocrFilePreviews = buildOcrFilePreviews\(ocrPayload\)/)
|
2026-06-18 22:12:24 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-22 23:47:28 +08:00
|
|
|
|
test('file preview cache replaces temporary object urls and never persists them', () => {
|
|
|
|
|
|
const merged = mergeFilePreviews(
|
|
|
|
|
|
[
|
|
|
|
|
|
{ filename: 'invoice.pdf', kind: 'pdf', url: 'blob:http://localhost/old-preview' },
|
|
|
|
|
|
{ filename: 'hotel.png', kind: 'image', url: 'data:image/png;base64,stable' }
|
|
|
|
|
|
],
|
|
|
|
|
|
[
|
|
|
|
|
|
{ filename: 'invoice.pdf', kind: 'pdf', url: 'blob:http://localhost/new-preview' },
|
|
|
|
|
|
{ filename: 'hotel.png', kind: 'image' }
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert.equal(merged.length, 2)
|
|
|
|
|
|
assert.equal(merged[0].url, 'blob:http://localhost/new-preview')
|
|
|
|
|
|
assert.equal(merged[1].url, 'data:image/png;base64,stable')
|
|
|
|
|
|
assert.deepEqual(filterPersistableFilePreviews(merged), [
|
|
|
|
|
|
{ filename: 'hotel.png', kind: 'image', url: 'data:image/png;base64,stable' }
|
|
|
|
|
|
])
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-22 16:00:19 +08:00
|
|
|
|
test('draft association query keeps a single candidate selectable in the conversation', () => {
|
|
|
|
|
|
const payload = buildDraftAssociationQueryPayload([
|
|
|
|
|
|
{
|
|
|
|
|
|
id: 'claim-1',
|
|
|
|
|
|
claim_no: 'EXP-202605-001',
|
|
|
|
|
|
status: 'draft',
|
|
|
|
|
|
expense_type: 'travel',
|
|
|
|
|
|
reason: '上海出差',
|
|
|
|
|
|
amount: 1280
|
|
|
|
|
|
}
|
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
|
|
assert.equal(payload.selectionMode, 'draft_association')
|
|
|
|
|
|
assert.equal(payload.title, '选择关联草稿')
|
|
|
|
|
|
assert.equal(payload.records.length, 1)
|
|
|
|
|
|
assert.equal(payload.records[0].claimId, 'claim-1')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('expense query payload keeps structured risk items for claim-level risk drilldown', () => {
|
|
|
|
|
|
const payload = normalizeExpenseQueryPayload({
|
|
|
|
|
|
result_type: 'expense_claim_list',
|
|
|
|
|
|
records: [
|
|
|
|
|
|
{
|
|
|
|
|
|
claim_id: 'claim-risk',
|
|
|
|
|
|
claim_no: 'EXP-202605-009',
|
|
|
|
|
|
amount: 880,
|
|
|
|
|
|
risk_flags: [
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'hotel-limit',
|
|
|
|
|
|
level: 'high',
|
|
|
|
|
|
level_label: '高风险',
|
|
|
|
|
|
title: '酒店超标',
|
|
|
|
|
|
summary: '住宿金额超过城市标准',
|
|
|
|
|
|
detail: '上海 P5 住宿标准为 600 元,本次 880 元。'
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
assert.equal(payload.records[0].riskItems.length, 1)
|
|
|
|
|
|
assert.equal(payload.records[0].riskItems[0].levelLabel, '高风险')
|
|
|
|
|
|
assert.equal(payload.records[0].riskItems[0].summary, '住宿金额超过城市标准')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-22 23:47:28 +08:00
|
|
|
|
test('expense query info items render as prompts instead of low risk', () => {
|
|
|
|
|
|
const payload = normalizeExpenseQueryPayload({
|
|
|
|
|
|
result_type: 'expense_claim_list',
|
|
|
|
|
|
records: [
|
|
|
|
|
|
{
|
|
|
|
|
|
claim_id: 'claim-info',
|
|
|
|
|
|
claim_no: 'EXP-202605-010',
|
|
|
|
|
|
amount: 59.1,
|
|
|
|
|
|
risk_flags: [
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'normal-tip',
|
|
|
|
|
|
level: 'info',
|
|
|
|
|
|
title: '票据提示',
|
|
|
|
|
|
summary: '票据已识别,当前没有异常。'
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
assert.equal(payload.records[0].riskItems[0].levelLabel, '提示')
|
|
|
|
|
|
assert.notEqual(payload.records[0].riskItems[0].levelLabel, '低风险')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-26 09:15:14 +08:00
|
|
|
|
test('expense query hint guides users to the document center after the top five results', () => {
|
2026-05-22 16:00:19 +08:00
|
|
|
|
const payload = normalizeExpenseQueryPayload({
|
|
|
|
|
|
result_type: 'expense_claim_list',
|
|
|
|
|
|
title: '最近 5 条你的归档报销单',
|
|
|
|
|
|
scope_label: '你的归档报销单',
|
|
|
|
|
|
record_count: 8,
|
|
|
|
|
|
preview_count: 5,
|
|
|
|
|
|
preview_limit: 5,
|
|
|
|
|
|
records: [
|
|
|
|
|
|
{ claim_id: 'claim-1', claim_no: 'EXP-1', amount: 100 }
|
|
|
|
|
|
]
|
|
|
|
|
|
})
|
|
|
|
|
|
const hint = buildExpenseQueryHint(payload)
|
|
|
|
|
|
|
|
|
|
|
|
assert.match(hint, /最近的 5 条记录/)
|
|
|
|
|
|
assert.match(hint, new RegExp(`\\[\\*\\*这里\\*\\*\\]\\(${EXPENSE_CENTER_HREF}\\)`))
|
2026-05-22 08:58:59 +08:00
|
|
|
|
})
|