Files
X-Financial/web/tests/attachment-association-confirmation.test.mjs
caoxiaozhu 5b388d08c0 feat: 增强知识库索引与设置页面模块化拆分
扩展知识库索引任务和 RAG 检索支持增量入库和文档去重,优
化本体检测和规则匹配精度,前端设置页面拆分为 LLM、邮件
和 Hermes 员工同步子面板并重构样式,新增日志详情组件和
知识入库日志模型,补充单元测试覆盖。
2026-05-22 23:47:28 +08:00

277 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import {
ATTACHMENT_ASSOCIATION_CONFIRM_HREF,
buildAttachmentAssociationConfirmationMessage,
buildOcrFilePreviews,
buildReviewFilePreviewsFromReviewPayload,
buildUnsavedDraftAttachmentConfirmationMessage,
filterPersistableFilePreviews,
mergeFilePreviews
} from '../src/views/scripts/travelReimbursementAttachmentModel.js'
import {
buildDraftAssociationQueryPayload,
buildExpenseQueryHint,
EXPENSE_CENTER_HREF,
normalizeExpenseQueryPayload
} from '../src/views/scripts/travelReimbursementExpenseQueryModel.js'
import { renderMarkdown } from '../src/utils/markdown.js'
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, /已识别附件信息:/)
assert.match(message, /> \*\*附件 1train-ticket\.pdf\*\*/)
assert.match(message, /附件类型:差旅票据/)
assert.match(message, /行程:武汉-上海/)
assert.match(message, /票价354.00/)
assert.match(message, /草稿单号EXP-202605-001/)
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>附件 12月20 武汉-上海\.pdf<\/strong>/)
assert.match(rendered, /<strong>附件 22月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'
)
const flowSource = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementFlow.js', import.meta.url)),
'utf8'
)
const conversationSource = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelReimbursementConversationModel.js', import.meta.url)),
'utf8'
)
assert.doesNotMatch(viewSource, /检测到你已有单据事件|uploadDecisionDialogOpen|continueExistingUpload|createNewUploadDocument/)
assert.doesNotMatch(submitComposerSource, /uploadDecisionDialogOpen|hasExistingDocumentEvent|skipUploadDecisionPrompt/)
assert.doesNotMatch(submitComposerSource, /查询可关联草稿失败,已继续按新单据识别/)
assert.match(
submitComposerSource,
/const claims = await fetchExpenseClaims\(\)[\s\S]*const queryPayload = buildDraftAssociationQueryPayload\(claims\)[\s\S]*meta: \['等待选择关联单据'\][\s\S]*queryPayload/
)
assert.match(submitComposerSource, /meta: \['单据查询失败'\][\s\S]*return null/)
assert.match(
submitComposerSource,
/files\.length[\s\S]*!resolvedUploadDisposition[\s\S]*!options\.skipDraftAssociationPrompt[\s\S]*!reviewAction/
)
assert.match(submitComposerSource, /mode:\s*'save_then_associate'/)
assert.match(submitComposerSource, /review_action:\s*'save_draft'[\s\S]*review_action:\s*'link_to_existing_draft'/)
assert.match(submitComposerSource, /appendToCurrentFlow:\s*true/)
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'/)
assert.match(flowSource, /responseMessage\.includes\('关联'\)[\s\S]*key:\s*'attachment-association'/)
assert.match(conversationSource, /'attachment-association':\s*\{[\s\S]*title:\s*'票据关联草稿'/)
})
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>/)
})
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 }])
})
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' }
])
})
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, '住宿金额超过城市标准')
})
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, '低风险')
})
test('expense query hint guides users to the reimbursement center after the top five results', () => {
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}\\)`))
})