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, /> \*\*附件 1:train-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, /
/) const questionIndex = rendered.indexOf('请问是否确定将票据信息归集到单据') const attachmentCardCloseIndex = rendered.indexOf('
') assert.ok(attachmentCardCloseIndex > -1 && questionIndex > attachmentCardCloseIndex) const attachmentCardHtml = rendered.slice( rendered.indexOf('
'), attachmentCardCloseIndex ) assert.doesNotMatch(attachmentCardHtml, /请问是否确定将票据信息归集到单据/) assert.match(rendered, /

/) assert.match(rendered, /确认<\/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, /附件 1:2月20 武汉-上海\.pdf<\/strong>/) assert.match(rendered, /附件 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' ) 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(flowSource, /'draft-risk-review'/) assert.match(flowSource, /草稿风险识别/) assert.match(conversationSource, /'attachment-association':\s*\{[\s\S]*title:\s*'票据关联草稿'/) assert.match(conversationSource, /'draft-risk-review':\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, /确定<\/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 document 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}\\)`)) })