import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' import { fileURLToPath } from 'node:url' import { buildApplicationPreviewFooterMessage, buildApplicationPreviewRows, buildApplicationPreviewSubmitText, buildApplicationTemplatePreview, applyApplicationPolicyEstimateResult, buildApplicationPolicyEstimateRequest, buildLocalApplicationPreview, buildLocalApplicationPreviewMessage, buildModelRefinedApplicationPreview, normalizeApplicationPreview, shouldUseLocalApplicationPreview } from '../src/utils/expenseApplicationPreview.js' import { renderMarkdown } from '../src/utils/markdown.js' import { createMessage as createConversationMessage, hasMeaningfulSessionMessages } from '../src/views/scripts/travelReimbursementConversationModel.js' const submitComposerScript = readFileSync( fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)), 'utf8' ) const createViewScript = readFileSync( fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)), 'utf8' ) const createViewTemplate = readFileSync( fileURLToPath(new URL('../src/views/TravelReimbursementCreateView.vue', import.meta.url)), 'utf8' ) const messageItemTemplate = readFileSync( fileURLToPath(new URL('../src/components/travel/TravelReimbursementMessageItem.vue', import.meta.url)), 'utf8' ) const messageItemStyles = readFileSync( fileURLToPath(new URL('../src/assets/styles/components/travel-reimbursement-message-item.css', import.meta.url)), 'utf8' ) const conversationModelScript = readFileSync( fileURLToPath(new URL('../src/views/scripts/travelReimbursementConversationModel.js', import.meta.url)), 'utf8' ) const previewEditorScript = readFileSync( fileURLToPath(new URL('../src/views/scripts/useApplicationPreviewEditor.js', import.meta.url)), 'utf8' ) test('application intent uses local preview instead of immediate orchestrator call', () => { const prompt = '申请 2026-05-20 至 2026-05-23 去上海支撑上海电力部署项目,出差3天,高铁,预计金额2358元' assert.equal( shouldUseLocalApplicationPreview(prompt, { sessionType: 'application', attachmentCount: 0, reviewAction: '', systemGenerated: false }), true ) assert.equal( shouldUseLocalApplicationPreview('帮我查询申请状态', { sessionType: 'application', attachmentCount: 0, reviewAction: '', systemGenerated: false }), false ) const preview = buildLocalApplicationPreview(prompt, { name: '李文静', departmentName: '财务部', grade: 'P5' }) assert.equal(preview.fields.applicationType, '差旅费用申请') assert.equal(preview.fields.time, '2026-05-20 至 2026-05-23') assert.equal(preview.fields.location, '上海') assert.equal(preview.fields.days, '3天') assert.equal(preview.fields.transportMode, '火车') assert.equal(preview.fields.amount, '2358元') assert.equal(preview.fields.grade, 'P5') assert.equal(preview.readyToSubmit, true) assert.doesNotMatch(buildLocalApplicationPreviewMessage(preview), /#application-submit/) assert.match(buildApplicationPreviewFooterMessage(preview), /#application-submit/) assert.match(buildLocalApplicationPreviewMessage(preview), /点击对应行即可直接编辑/) }) test('application preview renders ordered editable rows and submit text uses edited values', () => { const preview = buildLocalApplicationPreview('申请 2026-05-25 至 2026-05-28 去新疆,伊犁出差,服务美团业务部署,火车,预计费用1800元', { name: '李文静', grade: 'P5' }) assert.equal(preview.fields.location, '新疆,伊犁') assert.equal(preview.fields.reason, '服务美团业务部署') const editedPreview = normalizeApplicationPreview({ ...preview, fields: { ...preview.fields, reason: '客户现场项目支持', amount: '1900元' } }) const rows = buildApplicationPreviewRows(editedPreview) assert.deepEqual( rows.map((row) => row.label), ['申请类型', '职级', '发生时间', '地点', '事由', '天数', '出行方式', '住宿上限/天', '补贴标准/天', '交通费用口径', '规则测算参考', '用户预估费用'] ) assert.equal(rows.find((row) => row.key === 'amount')?.value, '1900元') assert.equal(rows.find((row) => row.key === 'amount')?.highlight, true) assert.equal(rows.find((row) => row.key === 'grade')?.editable, false) assert.equal(rows.find((row) => row.key === 'lodgingDailyCap')?.editable, false) assert.match(buildApplicationPreviewSubmitText(editedPreview), /事由:客户现场项目支持/) assert.match(buildApplicationPreviewSubmitText(editedPreview), /用户预估费用:1900元/) }) test('application preview cleans empty time labels and keeps only business reason', () => { const preview = buildLocalApplicationPreview('发生时间:,去九江出差3天,服务美团业务部署,预计费用1800元,火车', { name: '李文静', grade: 'P5' }) assert.equal(preview.fields.location, '九江') assert.equal(preview.fields.days, '3天') assert.equal(preview.fields.reason, '服务美团业务部署') assert.equal(preview.fields.transportMode, '火车') assert.doesNotMatch(preview.fields.reason, /发生时间|去九江|出差3天/) }) test('application preview can be refined by ontology model extraction', () => { const rawText = '发生时间:,去九江出差3天,服务美团业务部署,预计费用1800元,火车' const localPreview = buildLocalApplicationPreview(rawText, { name: '李文静', grade: 'P5' }) const refinedPreview = buildModelRefinedApplicationPreview( localPreview, { parse_strategy: 'llm_primary', entities: [ { type: 'expense_type', value: '差旅费', normalized_value: 'travel' }, { type: 'location', value: '九江', normalized_value: '九江' }, { type: 'reason', value: '服务美团业务部署', normalized_value: '服务美团业务部署' }, { type: 'transport_mode', value: '火车', normalized_value: '火车' }, { type: 'amount', value: '1800元', normalized_value: '1800' } ], time_range: {}, missing_slots: [] }, rawText, { name: '李文静', grade: 'P5' } ) assert.equal(refinedPreview.modelRefined, true) assert.equal(refinedPreview.parseStrategy, 'llm_primary') assert.equal(refinedPreview.modelReviewStatus, 'completed') assert.equal(refinedPreview.fields.applicationType, '差旅费用申请') assert.equal(refinedPreview.fields.time, '') assert.equal(refinedPreview.fields.reason, '服务美团业务部署') assert.equal(refinedPreview.fields.transportMode, '火车') }) test('application preview keeps rule fallback distinct from model reviewed result', () => { const rawText = '申请 2026-05-20 至 2026-05-23 去上海支撑服务器部署,出差3天,火车,预计费用1800元' const localPreview = buildLocalApplicationPreview(rawText, { name: '李文静', grade: 'P5' }) const fallbackPreview = buildModelRefinedApplicationPreview( localPreview, { parse_strategy: 'rule_fallback', entities: [ { type: 'expense_type', value: '差旅费', normalized_value: 'travel' }, { type: 'location', value: '上海', normalized_value: '上海' }, { type: 'amount', value: '1800元', normalized_value: '1800' } ], time_range: { start: '2026-05-20', end: '2026-05-23' }, missing_slots: [] }, rawText, { name: '李文静', grade: 'P5' } ) const message = buildLocalApplicationPreviewMessage(fallbackPreview) const footer = buildApplicationPreviewFooterMessage(fallbackPreview) assert.equal(fallbackPreview.modelReviewStatus, 'fallback') assert.match(message, /规则兜底/) assert.match(footer, /请确认上述的信息是否填写正确/) assert.match(footer, /#application-submit/) }) test('application preview with missing budget stays in chat and asks for补充信息', () => { const preview = buildLocalApplicationPreview('我想申请去北京出差,高铁,但是不知道预算', { name: '李文静', grade: 'P5' }) assert.equal(preview.fields.amount, '待测算') assert.equal(preview.readyToSubmit, false) assert.match(buildLocalApplicationPreviewMessage(preview), /下方表格/) assert.doesNotMatch(buildLocalApplicationPreviewMessage(preview), /当前还需要补充/) assert.match(buildApplicationPreviewFooterMessage(preview), /当前还需要补充/) assert.doesNotMatch(buildLocalApplicationPreviewMessage(preview), /#application-submit/) }) test('application quick start renders a template without model review', () => { const preview = buildApplicationTemplatePreview({ name: '李文静', departmentName: '财务部', grade: 'P5' }) const message = buildLocalApplicationPreviewMessage(preview) assert.equal(preview.modelReviewStatus, 'template') assert.equal(preview.fields.applicationType, '费用申请') assert.equal(preview.fields.applicant, '李文静') assert.equal(preview.fields.department, '财务部') assert.equal(preview.fields.grade, 'P5') assert.equal(buildApplicationPreviewRows(preview).find((row) => row.key === 'grade')?.editable, false) assert.match(message, /不调用大模型/) assert.match(message, /点击对应行直接填写/) assert.doesNotMatch(message, /#application-submit/) assert.match(buildApplicationPreviewFooterMessage(preview), /当前还需要补充/) }) test('application quick start template counts as deletable session content', () => { const welcomeMessage = createConversationMessage('assistant', '欢迎语', [], { isWelcome: true, welcomeQuickActions: [{ label: '快速发起申请', action: 'start_guided_application' }] }) const templateMessage = createConversationMessage('assistant', '申请模板', [], { applicationPreview: buildApplicationTemplatePreview({ name: '测试员工', departmentName: '财务部', grade: 'P5' }) }) assert.equal(hasMeaningfulSessionMessages([welcomeMessage]), false) assert.equal(hasMeaningfulSessionMessages([welcomeMessage, templateMessage]), true) }) test('application session shows intent flow, persists preview, and supports inline table edit', () => { assert.match(submitComposerScript, /shouldUseLocalApplicationPreview/) assert.match(submitComposerScript, /buildLocalApplicationPreviewMessage/) assert.match(submitComposerScript, /buildApplicationPreviewWithModelReview/) assert.match(submitComposerScript, /fetchOntologyParse/) assert.match(submitComposerScript, /calculateTravelReimbursement/) assert.match(submitComposerScript, /buildApplicationPolicyEstimateRequest/) assert.match(submitComposerScript, /模型复核中/) assert.match(submitComposerScript, /startFlowStep\('intent'/) assert.match(submitComposerScript, /startFlowStep\('application-review-preview'/) assert.match(submitComposerScript, /completeFlowStep\('intent'/) assert.doesNotMatch(submitComposerScript, /insightPanelCollapsed\.value = true/) assert.doesNotMatch(submitComposerScript, /void refineApplicationPreviewWithModel/) assert.match(submitComposerScript, /return null[\s\S]*const hasUnsavedReviewDraft/) assert.ok( submitComposerScript.indexOf('shouldUseLocalApplicationPreview') < submitComposerScript.indexOf('const payload = await runOrchestrator') ) assert.match(createViewScript, /const isApplicationSession = computed/) assert.match(createViewScript, /insightPanelCollapsed,/) assert.doesNotMatch(createViewScript, /if \(isApplicationSession\.value\) \{\s*return false\s*\}/) assert.match(createViewScript, /activeFlowSteps\.value\.length > 0/) assert.match(createViewScript, /useApplicationPreviewEditor/) assert.match(createViewScript, /message-bubble-application-preview/) assert.match(createViewScript, /buildApplicationPreviewFooterMessage/) assert.match(createViewScript, /function buildApplicationPreviewFooterText\(message\)/) assert.match(createViewScript, /buildApplicationPreviewSubmitText/) assert.match(createViewScript, /user_input_text: applicationSubmitText/) assert.match(conversationModelScript, /applicationPreview: null/) assert.match(conversationModelScript, /applicationPreview: message\.applicationPreview \|\| null/) assert.match(conversationModelScript, /\|\| message\.applicationPreview/) assert.match(createViewScript, /hasMeaningfulSessionMessages\(messages\.value\)/) assert.match(messageItemTemplate, /class="application-preview-table"/) assert.match(messageItemTemplate, /class="application-preview-footer application-preview-footer-missing"/) assert.match(messageItemTemplate, /application-preview-missing-chip/) assert.match(messageItemTemplate, /当前还需要补充:/) assert.match(messageItemTemplate, /补齐后我再帮您提交申请。/) assert.match(messageItemTemplate, /class="application-preview-footer message-answer-content message-answer-markdown"/) assert.match(messageItemTemplate, /v-html="ui\.renderMarkdown\(ui\.buildApplicationPreviewFooterText\(message\)\)"/) assert.match(createViewTemplate, /'has-insight': hasInsightPanelContent && showInsightPanel/) assert.match(messageItemTemplate, /v-model="ui\.applicationPreviewEditor\.draftValue"/) assert.match(messageItemTemplate, /application-preview-select/) assert.match(messageItemTemplate, /resolveApplicationPreviewEditorOptions/) assert.match(messageItemTemplate, /row\.editable && !ui\.isApplicationPreviewEditing\(message, row\.key\).*ui\.openApplicationPreviewEditor\(message, row\.key, row\.value\)"/) assert.match(messageItemTemplate, /@keydown\.enter\.prevent="row\.editable && !ui\.isApplicationPreviewEditing\(message, row\.key\).*ui\.openApplicationPreviewEditor\(message, row\.key, row\.value\)"/) assert.match(messageItemTemplate, /@keydown\.stop="ui\.handleApplicationPreviewEditorKeydown\(\$event, message\)"/) assert.match(messageItemTemplate, /mdi mdi-pencil-outline/) assert.match(messageItemTemplate, /@click\.stop="ui\.openApplicationPreviewEditor\(message, row\.key, row\.value\)"/) assert.match(messageItemTemplate, /openApplicationPreviewEditor/) assert.match(messageItemTemplate, /commitApplicationPreviewEditor/) assert.match(createViewScript, /resolveApplicationPreviewMissingFields/) assert.match(previewEditorScript, /normalizeApplicationPreview/) assert.match(previewEditorScript, /APPLICATION_TRANSPORT_MODE_OPTIONS/) assert.match(previewEditorScript, /buildLocalApplicationPreviewMessage/) assert.match(previewEditorScript, /targetRow\.editable === false/) assert.match(previewEditorScript, /\[editor\.fieldKey\]: nextValue/) assert.match(messageItemStyles, /\.application-preview-row\.missing \{[\s\S]*--theme-primary-rgb/) assert.match(messageItemStyles, /\.application-preview-table \{[\s\S]*border: 1px solid #d7e4f2;[\s\S]*background: #ffffff;/) assert.match(messageItemStyles, /\.application-preview-row \{[\s\S]*grid-template-columns: 108px minmax\(0, 1fr\);/) assert.match(messageItemStyles, /\.application-preview-text \{[\s\S]*overflow-wrap: anywhere;/) assert.match(messageItemStyles, /\.application-preview-select \{[\s\S]*width: 100%;/) assert.match(messageItemStyles, /\.application-preview-footer \{[\s\S]*margin-top: 48px;/) assert.match(messageItemStyles, /\.message-answer-markdown :deep\(\.markdown-action-link\) \{[\s\S]*text-decoration: underline;/) assert.match(messageItemStyles, /\.application-preview-footer-missing \{[\s\S]*margin-top: 48px;[\s\S]*background: transparent;/) assert.match(messageItemStyles, /\.application-preview-missing-chip \{[\s\S]*background: rgba\(var\(--theme-primary-rgb/) }) test('assistant markdown tables render with component-scoped table styling', () => { const rendered = renderMarkdown([ '| 项目 | 标准口径 | 天数 | 小计 |', '| --- | --- | ---: | ---: |', '| 住宿费 | 武汉 / P5 标准:330.00 元/天 | 1 | 330.00 元 |', '| 出差补贴 | 其他地区:伙食 55.00 元 + 基本 35.00 元 | 1 | 90.00 元 |' ].join('\n')) assert.match(rendered, /
| { const preview = buildLocalApplicationPreview('申请 2026-05-25 至 2026-05-28 去上海出差3天,服务项目部署,火车,预计费用1800元', { name: '李文静', grade: 'P5' }) const request = buildApplicationPolicyEstimateRequest(preview, { grade: 'P5' }) assert.equal(request.canCalculate, true) assert.deepEqual(request.payload, { days: 3, location: '上海', grade: 'P5' }) const estimatedPreview = applyApplicationPolicyEstimateResult(preview, { days: 3, location: '上海', matched_city: '上海', grade: 'P5', hotel_rate: 600, hotel_amount: 1800, total_allowance_rate: 120, allowance_amount: 360, total_amount: 2160, rule_name: '公司差旅费报销规则', rule_version: '2026版' }, { grade: 'P5' }) assert.equal(estimatedPreview.fields.lodgingDailyCap, '600元/天') assert.equal(estimatedPreview.fields.subsidyDailyCap, '120元/天') assert.match(estimatedPreview.fields.transportPolicy, /实报实销/) assert.match(estimatedPreview.fields.policyEstimate, /2,160元/) assert.equal(buildApplicationPreviewRows(estimatedPreview).find((row) => row.key === 'policyEstimate')?.highlight, true) }) |
|---|