Files
X-Financial/web/tests/expense-application-fast-preview.test.mjs
caoxiaozhu 7d32eae74e feat: 新增预算助手报告组件并优化报销交互细节
新增预算助手报告视图模型和组件,优化报销洞察面板和消息项
样式细节,完善预算中心页面布局和文档中心视图,增强报销创
建会话管理和提交编排器,调整 Vite 构建配置,补充单元测试。
2026-05-27 12:27:17 +08:00

335 lines
17 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 {
buildApplicationPreviewFooterMessage,
buildApplicationPreviewRows,
buildApplicationPreviewSubmitText,
buildApplicationTemplatePreview,
applyApplicationPolicyEstimateResult,
buildApplicationPolicyEstimateRequest,
buildLocalApplicationPreview,
buildLocalApplicationPreviewMessage,
buildModelRefinedApplicationPreview,
normalizeApplicationPreview,
shouldUseLocalApplicationPreview
} from '../src/utils/expenseApplicationPreview.js'
import { renderMarkdown } from '../src/utils/markdown.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 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.match(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, /flowSteps\.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(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, /<div class="markdown-table-wrap">/)
assert.match(rendered, /<table>/)
assert.match(rendered, /<th/)
assert.match(rendered, /<td/)
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(\.markdown-table-wrap\) \{[\s\S]*overflow-x: auto;[\s\S]*border: 1px solid #dbe4ee;/)
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(table\) \{[\s\S]*min-width: 460px;[\s\S]*border-collapse: separate;/)
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(th\),[\s\S]*\.message-answer-markdown :deep\(td\) \{[\s\S]*padding: 8px 10px;/)
})
test('application preview merges rule center travel estimate into highlighted rows', () => {
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)
})