Files
X-Financial/web/tests/expense-application-fast-preview.test.mjs
caoxiaozhu 15006a05a7 feat: 数字员工财务报告体系与定时提醒及看板快照调度
- 新增数字员工财务报告生成、邮件投递与渲染调度器
- 引入员工画像扫描调度与定时提醒任务
- 完善财务看板快照、排行口径与部门人员占比计算
- 优化数字员工工作看板仪表盘与技能目录
- 增强前端总览页图表、工作台摘要与顶部导航栏交互
- 新增差旅申请规划推动提醒与报销创建会话状态管理
- 补充财务报告、看板调度、数字员工工作记录测试覆盖
2026-06-03 09:25:23 +08:00

865 lines
42 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 { ref } from 'vue'
import {
applyApplicationBusinessTimeContext,
buildApplicationPreviewFooterMessage,
buildApplicationPreviewRows,
buildApplicationPreviewSubmitText,
buildApplicationTemplatePreview,
applyApplicationPolicyEstimateResult,
buildApplicationPolicyEstimateRequest,
buildLocalApplicationPreview,
buildLocalApplicationPreviewMessage,
buildModelRefinedApplicationPreview,
normalizeApplicationPreview,
resolveApplicationTimeLabel,
shouldUseLocalApplicationPreview
} from '../src/utils/expenseApplicationPreview.js'
import {
buildMockApplicationTransportEstimate,
resolveMockApplicationTransportWaitMs,
buildSystemApplicationEstimate
} from '../src/utils/expenseApplicationEstimate.js'
import {
TRAVEL_PLANNING_ACTION_GENERATE,
TRAVEL_PLANNING_ACTION_SKIP,
buildTravelPlanningNudgeMessage,
buildTravelPlanningRecommendation,
buildTravelPlanningSuggestedActions
} from '../src/utils/travelApplicationPlanning.js'
import { renderMarkdown } from '../src/utils/markdown.js'
import {
createMessage as createConversationMessage,
hasMeaningfulSessionMessages
} from '../src/views/scripts/travelReimbursementConversationModel.js'
import { useTravelReimbursementFlow } from '../src/views/scripts/useTravelReimbursementFlow.js'
import { useApplicationPreviewEditor } from '../src/views/scripts/useApplicationPreviewEditor.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 applicationMessageStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/components/travel-reimbursement-message-application.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'
)
const flowScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementFlow.js', import.meta.url)),
'utf8'
)
function createFlowHarness() {
return useTravelReimbursementFlow({
activeSessionType: ref('application'),
reviewDrawerMode: ref(''),
insightPanelCollapsed: ref(true),
isKnowledgeSession: ref(false),
fetchAgentRunDetail: async () => null,
buildLocalIntentPreview: () => '本地意图预览',
buildLocalExtractionProgressMessages: () => ['正在抽取信息'],
summarizeSemanticIntentDetail: () => '模型已完成意图识别',
summarizeSemanticParseDetail: () => '模型已完成信息抽取',
SCENARIO_LABELS: {},
INTENT_LABELS: {},
EXPENSE_TYPE_LABELS: {},
FLOW_STEP_FALLBACKS: {
intent: { title: '意图识别', tool: 'SemanticRouter', runningText: '正在识别业务意图...', completedText: '已识别业务意图' },
extraction: { title: '信息抽取', tool: 'SemanticExtractor', runningText: '正在抽取关键信息...', completedText: '已抽取关键信息' },
'application-submit-success': { title: '申请单提交成功', tool: 'ApplicationSubmit', runningText: '正在提交申请单...', completedText: '申请单提交成功' }
},
REVIEW_DRAWER_MODE_FLOW: 'flow',
REVIEW_DRAWER_MODE_REVIEW: 'review',
FLOW_STEP_STATUS_PENDING: 'pending',
FLOW_STEP_STATUS_RUNNING: 'running',
FLOW_STEP_STATUS_COMPLETED: 'completed',
FLOW_STEP_STATUS_FAILED: 'failed'
})
}
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: '财务部',
position: '财务分析师',
managerName: '王强',
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.applicant, '李文静')
assert.equal(preview.fields.grade, 'P5')
assert.equal(preview.fields.department, '财务部')
assert.equal(preview.fields.position, '财务分析师')
assert.equal(preview.fields.managerName, '王强')
assert.equal(preview.readyToSubmit, true)
assert.doesNotMatch(buildLocalApplicationPreviewMessage(preview), /#application-submit/)
assert.match(buildApplicationPreviewFooterMessage(preview), /#application-submit/)
assert.match(buildLocalApplicationPreviewMessage(preview), /点击对应行即可直接编辑/)
})
test('travel application submit can continue with conversational planning recommendation', () => {
const preview = normalizeApplicationPreview({
fields: {
applicationType: '差旅费用申请',
time: '2026-02-20 至 2026-02-23',
location: '上海市',
reason: '支撑国网仿生产环境建设',
days: '4天',
transportMode: '火车'
}
})
const draftPayload = { claim_no: 'AP-202606030001-ABCDE123' }
const nudge = buildTravelPlanningNudgeMessage(preview, draftPayload)
const actions = buildTravelPlanningSuggestedActions(preview, draftPayload)
const recommendation = buildTravelPlanningRecommendation(preview, draftPayload)
assert.match(nudge, /上海市差旅申请已经提交/)
assert.match(nudge, /2026-02-20 至 2026-02-23/)
assert.deepEqual(actions.map((item) => item.action_type), [
TRAVEL_PLANNING_ACTION_GENERATE,
TRAVEL_PLANNING_ACTION_SKIP
])
assert.match(recommendation, /轻量行程规划/)
assert.match(recommendation, /优先看上午到中午抵达 上海市 的火车班次/)
assert.match(recommendation, /客户现场周边/)
assert.match(recommendation, /AP-202606030001-ABCDE123/)
})
test('application preview renders ordered editable rows and submit text uses edited values', () => {
const preview = buildLocalApplicationPreview('申请 2026-05-25 至 2026-05-28 去新疆伊犁出差服务美团业务部署火车预计费用1800元', {
name: '李文静',
departmentName: '财务部',
position: '财务分析师',
managerName: '王强',
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.match(buildApplicationPreviewSubmitText(editedPreview), /出发时间2026-05-25/)
assert.match(buildApplicationPreviewSubmitText(editedPreview), /返回时间2026-05-28/)
assert.doesNotMatch(buildApplicationPreviewSubmitText(editedPreview), /发生时间:/)
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 === 'amount')?.editable, false)
assert.equal(rows.find((row) => row.key === 'applicant')?.editable, false)
assert.equal(rows.find((row) => row.key === 'grade')?.editable, false)
assert.equal(rows.find((row) => row.key === 'department')?.editable, false)
assert.equal(rows.find((row) => row.key === 'position')?.editable, false)
assert.equal(rows.find((row) => row.key === 'managerName')?.editable, false)
assert.equal(rows.find((row) => row.key === 'lodgingDailyCap')?.editable, false)
assert.match(buildApplicationPreviewSubmitText(editedPreview), /姓名:李文静/)
assert.match(buildApplicationPreviewSubmitText(editedPreview), /部门:财务部/)
assert.match(buildApplicationPreviewSubmitText(editedPreview), /岗位:财务分析师/)
assert.match(buildApplicationPreviewSubmitText(editedPreview), /直属领导:王强/)
assert.match(buildApplicationPreviewSubmitText(editedPreview), /事由:客户现场项目支持/)
assert.match(buildApplicationPreviewSubmitText(editedPreview), /系统预估费用1900元/)
})
test('application estimate builds deterministic mock transport amount and total', () => {
const trainEstimate = buildMockApplicationTransportEstimate({ transportMode: '高铁', location: '上海' })
const datedTrainEstimate = buildMockApplicationTransportEstimate({
transportMode: '高铁',
location: '上海',
time: '2026-05-25 至 2026-05-28'
})
const flightEstimate = buildMockApplicationTransportEstimate({ transportMode: '机票', location: '新疆,伊犁' })
const shipEstimate = buildMockApplicationTransportEstimate({ transportMode: '船票', location: '厦门' })
const totalEstimate = buildSystemApplicationEstimate({
transportMode: '火车',
location: '上海',
lodgingAmount: 1800,
allowanceAmount: 360
})
const datedTotalEstimate = buildSystemApplicationEstimate({
transportMode: '火车',
location: '上海',
time: '2026-05-25 至 2026-05-28',
lodgingAmount: 1800,
allowanceAmount: 360
})
assert.equal(trainEstimate.amountDisplay, '1,040')
assert.equal(datedTrainEstimate.queryDate, '2026-05-25')
assert.equal(datedTrainEstimate.amountDisplay, '1,100')
assert.equal(datedTrainEstimate.source, 'mock_ticket_price_query_v1')
assert.equal(datedTrainEstimate.basisText, '预估交通费用 1,100元')
assert.ok(datedTrainEstimate.simulatedLatencyMs >= 360)
assert.ok(datedTrainEstimate.simulatedLatencyMs <= 779)
assert.equal(resolveMockApplicationTransportWaitMs(datedTrainEstimate), 320)
assert.equal(flightEstimate.amountDisplay, '3,600')
assert.equal(shipEstimate.amountDisplay, '1,040')
assert.equal(totalEstimate.transportAmountDisplay, '1,040')
assert.equal(totalEstimate.totalAmountDisplay, '3,200')
assert.equal(datedTotalEstimate.transportAmountDisplay, '1,100')
assert.equal(datedTotalEstimate.totalAmountDisplay, '3,260')
})
test('application preview uses selected date range and business-specific time label', () => {
const preview = applyApplicationBusinessTimeContext(
buildLocalApplicationPreview(
'去上海出差4天支撑国网仿生产环境部署飞机',
{
name: '曹笑竹',
departmentName: '技术部',
position: '财务智能化产品经理',
managerName: '向万红',
grade: 'P5'
},
{ today: '2026-06-02' }
),
{
mode: 'range',
start_date: '2026-02-20',
end_date: '2026-02-23',
business_time: '2026-02-20 至 2026-02-23'
}
)
const rows = buildApplicationPreviewRows(preview)
const submitText = buildApplicationPreviewSubmitText(preview)
assert.equal(resolveApplicationTimeLabel(preview.fields.applicationType), '出发时间')
assert.equal(preview.fields.time, '2026-02-20 至 2026-02-23')
assert.equal(preview.fields.days, '4天')
assert.equal(preview.fields.reason, '支撑国网仿生产环境部署')
assert.equal(rows.find((row) => row.key === 'time')?.label, '出发时间')
assert.equal(rows.find((row) => row.key === 'time')?.value, '2026-02-20')
assert.equal(rows.find((row) => row.key === 'time_return')?.label, '返回时间')
assert.equal(rows.find((row) => row.key === 'time_return')?.value, '2026-02-23')
assert.match(submitText, /出发时间2026-02-20/)
assert.match(submitText, /返回时间2026-02-23/)
assert.match(submitText, /事由:支撑国网仿生产环境部署/)
assert.doesNotMatch(submitText, /发生时间:/)
})
test('application preview keeps labeled reason in structured travel form', () => {
const preview = buildLocalApplicationPreview([
'发生时间2026-02-20 至 2026-02-23',
'地点:上海',
'事由:支撑国网仿生产环境建设',
'天数4天'
].join('\n'), {
name: '曹笑竹',
grade: 'P5'
})
const rows = buildApplicationPreviewRows(preview)
assert.equal(preview.fields.applicationType, '差旅费用申请')
assert.equal(preview.fields.time, '2026-02-20 至 2026-02-23')
assert.equal(preview.fields.location, '上海')
assert.equal(preview.fields.reason, '支撑国网仿生产环境建设')
assert.equal(preview.fields.days, '4天')
assert.equal(rows.find((row) => row.key === 'reason')?.value, '支撑国网仿生产环境建设')
assert.equal(rows.find((row) => row.key === 'reason')?.missing, false)
assert.equal(rows.find((row) => row.key === 'time')?.label, '出发时间')
assert.equal(rows.find((row) => row.key === 'time_return')?.label, '返回时间')
})
test('application preview cleans empty time labels and keeps only business reason', () => {
const preview = buildLocalApplicationPreview('发生时间去九江出差3天服务美团业务部署预计费用1800元火车', {
name: '李文静',
grade: 'P5'
}, {
today: '2026-05-29'
})
assert.equal(preview.fields.time, '2026-05-29 至 2026-05-31')
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' }, { today: '2026-05-29' })
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, '2026-05-29 至 2026-05-31')
assert.equal(refinedPreview.fields.reason, '服务美团业务部署')
assert.equal(refinedPreview.fields.transportMode, '火车')
})
test('application preview precomputes a date range from today when only days are provided', () => {
const preview = buildLocalApplicationPreview(
'去北京出差3天支撑国网仿生产环境部署飞机预计费用12000元',
{ name: '李文静', grade: 'P5' },
{ today: '2026-05-29' }
)
assert.equal(preview.fields.time, '2026-05-29 至 2026-05-31')
assert.equal(preview.fields.days, '3天')
assert.equal(preview.readyToSubmit, true)
})
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: '财务部',
position: '财务分析师',
managerName: '王强',
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.position, '财务分析师')
assert.equal(preview.fields.managerName, '王强')
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.doesNotMatch(messageItemTemplate, /class="application-date-editor-layer"/)
assert.doesNotMatch(messageItemTemplate, /ui\.commitApplicationPreviewDateEditor\(message\)/)
assert.match(messageItemTemplate, /application-preview-date-chip/)
assert.match(messageItemTemplate, /申请单据已生成/)
assert.match(messageItemTemplate, /ui\.shouldShowDraftSavedCard\(message\)/)
assert.match(messageItemTemplate, /报销草稿已生成/)
assert.match(messageItemTemplate, /ui\.resolveReimbursementDraftClaimNo\(message\.draftPayload\)/)
assert.match(messageItemTemplate, /class="reimbursement-draft-link"/)
assert.match(messageItemTemplate, /查看详情/)
assert.doesNotMatch(messageItemTemplate, /ui\.buildReimbursementDraftSummaryItems\(message\.draftPayload\)/)
assert.doesNotMatch(messageItemTemplate, /可以继续上传票据,我会归集到这张草稿。/)
assert.ok(
messageItemTemplate.indexOf('class="draft-preview application-draft-preview"')
< messageItemTemplate.indexOf('class="message-detail-block review-message-block"')
)
assert.match(messageItemTemplate, /application-draft-head/)
assert.match(messageItemTemplate, /mdi mdi-file-document-check-outline/)
assert.match(messageItemTemplate, /mdi mdi-file-document-edit-outline/)
assert.match(messageItemTemplate, /'is-primary': item\.label === '单号'/)
assert.match(messageItemTemplate, /完整审批链、附件和明细可在单据详情中[\s\S]*application-draft-detail-link[\s\S]*>查看<\/button>/)
assert.doesNotMatch(messageItemTemplate, /application-draft-detail-btn/)
assert.match(messageItemTemplate, /ui\.openApplicationDraftDetail\(message\)/)
assert.match(messageItemTemplate, /<OperationFeedbackInlineCard/)
assert.match(messageItemTemplate, /ui\.isOperationFeedbackVisible\(message\)/)
assert.match(messageItemTemplate, /ui\.submitOperationFeedbackForMessage\(message, \$event\)/)
assert.match(submitComposerScript, /employee_grade:\s*user\.grade \|\| user\.employeeGrade \|\| user\.employee_grade/)
assert.match(submitComposerScript, /employeeGrade:\s*user\.grade \|\| user\.employeeGrade \|\| user\.employee_grade/)
assert.match(createViewTemplate, /'has-insight': hasInsightPanelContent && showInsightPanel/)
assert.match(messageItemTemplate, /v-model="ui\.applicationPreviewEditor\.draftValue"/)
assert.doesNotMatch(messageItemTemplate, /v-model="ui\.applicationPreviewEditor\.singleDate"/)
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(createViewScript, /function applyLinkedApplicationPreviewDateSelection/)
assert.match(createViewScript, /onComposerDateSelection: applyLinkedApplicationPreviewDateSelection/)
assert.match(createViewScript, /function openApplicationPreviewEditorFromUi/)
assert.match(createViewScript, /syncComposerDateFromApplicationEditor/)
assert.match(createViewScript, /function submitOperationFeedbackForMessage/)
assert.match(createViewTemplate, /handleComposerDateInputChange\('single'\)/)
assert.match(createViewTemplate, /handleComposerDateInputChange\('range-start'\)/)
assert.match(createViewTemplate, /handleComposerDateInputChange\('range-end'\)/)
assert.doesNotMatch(createViewTemplate, /@click="applyComposerDateSelection"/)
assert.match(previewEditorScript, /normalizeApplicationPreview/)
assert.match(previewEditorScript, /APPLICATION_TRANSPORT_MODE_OPTIONS/)
assert.match(previewEditorScript, /getTodayDateValue/)
assert.match(previewEditorScript, /buildLocalApplicationPreviewMessage/)
assert.match(previewEditorScript, /targetRow\.editable === false/)
assert.match(previewEditorScript, /\[editor\.fieldKey\]: nextValue/)
assert.match(previewEditorScript, /fieldKey === 'time'\) return 'date'/)
assert.match(previewEditorScript, /commitApplicationPreviewDateEditor/)
assert.match(messageItemStyles, /@import "\.\/travel-reimbursement-message-application\.css";/)
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/)
assert.doesNotMatch(applicationMessageStyles, /\.application-date-editor-layer/)
assert.match(applicationMessageStyles, /\.application-draft-preview \.application-draft-head \{[\s\S]*grid-template-columns: 36px minmax\(0, 1fr\) auto;/)
assert.match(applicationMessageStyles, /\.application-draft-brief \{[\s\S]*gap: 1px;[\s\S]*border: 1px solid #d7e4f2;[\s\S]*background: #d7e4f2;/)
assert.match(applicationMessageStyles, /\.application-draft-brief-item \{[\s\S]*border: 0;[\s\S]*background: #ffffff;/)
assert.doesNotMatch(applicationMessageStyles, /\.application-draft-brief-item:nth-child\(even\)/)
assert.match(applicationMessageStyles, /\.application-draft-brief-item\.is-primary \{[\s\S]*grid-column: 1 \/ -1;/)
assert.match(applicationMessageStyles, /\.application-draft-preview\.reimbursement-draft-preview \{[\s\S]*max-width: 520px;/)
assert.match(applicationMessageStyles, /\.reimbursement-draft-card \{[\s\S]*grid-template-columns: 30px minmax\(0, 1fr\);/)
assert.match(applicationMessageStyles, /\.reimbursement-draft-link \{[\s\S]*text-decoration: underline;/)
assert.match(flowScript, /application-submit-success/)
assert.match(flowScript, /function shouldHideToolCall/)
assert.match(flowScript, /semantic_ontology/)
assert.match(flowScript, /return null/)
assert.match(flowScript, /申请单提交成功/)
assert.match(submitComposerScript, /const isApplicationSubmitOperation = feedbackOperationType === 'submit_application'/)
assert.match(submitComposerScript, /if \(isApplicationSubmitOperation\) \{[\s\S]*startFlowStep\('application-submit-success'/)
assert.match(submitComposerScript, /else if \(rawText && !reviewAction\) \{[\s\S]*startFlowStep\('intent'/)
assert.match(submitComposerScript, /if \(!isApplicationSubmitOperation\) \{[\s\S]*startExpenseClaimDraftFlowStep/)
assert.match(flowScript, /function resolveDurationFromFields/)
assert.match(flowScript, /function resolveStartedTimestamp/)
assert.match(flowScript, /function resolveFinishedTimestamp/)
assert.match(flowScript, /syntheticTiming/)
assert.match(flowScript, /refreshCompleted/)
})
test('flow panel durations use backend timing instead of local preview delay', () => {
const flow = createFlowHarness()
flow.resetFlowRun({ startedAt: Date.parse('2026-05-29T00:00:00.000Z') })
flow.startFlowStep('intent', '正在识别业务意图...')
flow.completeFlowStep('intent', '本地预览完成', 80)
flow.startFlowStep('extraction', '正在抽取关键信息...')
flow.completeFlowStep('extraction', '本地抽取完成', 90)
flow.startFlowStep('application-submit-success', {
title: '申请单提交成功',
tool: 'ApplicationSubmit',
detail: '正在提交申请单...'
})
flow.completeFlowStep('application-submit-success', '本地提交完成', 100)
flow.mergeFlowRunDetail({
started_at: '2026-05-29T00:00:00.000Z',
finished_at: '2026-05-29T00:00:05.000Z',
status: 'succeeded',
semantic_parse: {},
ontology_json: {},
tool_calls: [
{
id: 'submit-1',
run_id: 'run-1',
tool_type: 'application',
tool_name: 'application.submit',
request_json: {},
response_json: { status: 'submitted', draft_payload: { status: 'submitted' } },
status: 'succeeded',
duration_ms: 2360,
created_at: '2026-05-29T00:00:04.000Z'
}
]
})
const durationByKey = Object.fromEntries(flow.flowSteps.value.map((step) => [step.key, step.durationMs]))
assert.equal(durationByKey.intent, 1400)
assert.equal(durationByKey.extraction, 2600)
assert.equal(durationByKey['application-submit-success'], 2360)
assert.equal(flow.flowTotalDurationText.value, '5.0s')
assert.equal(flow.formatFlowStepDuration({ status: 'completed', durationMs: 0 }), '--')
assert.equal(flow.formatFlowStepDuration({ status: 'completed', durationMs: null }), '--')
})
test('application submit confirmation flow only shows submit success step', () => {
const flow = createFlowHarness()
flow.resetFlowRun({ startedAt: Date.parse('2026-05-29T00:00:00.000Z') })
flow.startFlowStep('application-submit-success', {
title: '申请单提交成功',
tool: 'ApplicationSubmit',
detail: '正在提交费用申请...'
})
flow.completeFlowResult({
status: 'succeeded',
result: {
answer: '申请单据已生成,并已进入审批流程。',
draft_payload: {
draft_type: 'expense_application',
status: 'submitted',
claim_no: 'AP-20260602010101-ABCDEFGH',
approval_stage: '直属领导审批'
}
}
})
assert.deepEqual(flow.flowSteps.value.map((step) => step.key), ['application-submit-success'])
assert.deepEqual(flow.visibleFlowSteps.value.map((step) => step.key), ['application-submit-success'])
const submitStep = flow.flowSteps.value[0]
assert.equal(submitStep.status, 'completed')
assert.match(submitStep.detail, /AP-20260602010101-ABCDEFGH/)
assert.doesNotMatch(flow.flowSteps.value.map((step) => step.key).join(','), /intent|extraction/)
})
test('application duplicate confirmation flow marks submit step as blocked duplicate', () => {
const flow = createFlowHarness()
flow.resetFlowRun({ startedAt: Date.parse('2026-05-29T00:00:00.000Z') })
flow.startFlowStep('application-submit-success', {
title: '申请单提交成功',
tool: 'ApplicationSubmit',
detail: '正在提交费用申请...'
})
flow.completeFlowResult({
status: 'succeeded',
result: {
answer: [
'检测到同一申请人、同一申请类型、同一出发时间已存在申请单,系统没有重复创建。',
'已有申请单号AP-20260602010101-ABCDEFGH',
'当前节点:直属领导审批'
].join('\n')
}
})
assert.deepEqual(flow.flowSteps.value.map((step) => step.key), ['application-submit-success'])
const submitStep = flow.flowSteps.value[0]
assert.equal(submitStep.status, 'completed')
assert.equal(submitStep.title, '重复申请已拦截')
assert.match(submitStep.detail, /AP-20260602010101-ABCDEFGH/)
assert.doesNotMatch(submitStep.detail, /提交成功/)
})
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.equal(estimatedPreview.fields.transportPolicy, '预估交通费用 1,100元')
assert.doesNotMatch(estimatedPreview.fields.transportPolicy, /参考票价|查询耗时|2026-05-25|真实票据/)
assert.match(estimatedPreview.fields.policyEstimate, /交通 1,100元/)
assert.match(estimatedPreview.fields.policyEstimate, /3,260元/)
assert.equal(estimatedPreview.fields.transportEstimatedAmount, '1,100元')
assert.equal(estimatedPreview.fields.transportEstimateDate, '2026-05-25')
assert.match(estimatedPreview.fields.transportQueryLatencyMs, /^\d+ms$/)
assert.equal(estimatedPreview.fields.amount, '3,260元')
assert.equal(buildApplicationPreviewRows(estimatedPreview).find((row) => row.key === 'policyEstimate')?.highlight, true)
})
test('application preview editor refreshes transport estimate after mode change', async () => {
const preview = applyApplicationPolicyEstimateResult(
buildLocalApplicationPreview('申请 2026-05-25 至 2026-05-27 去上海出差3天服务项目部署', {
name: '李文静',
grade: 'P5'
}),
{
days: 3,
location: '上海',
matched_city: '上海',
grade: 'P5',
hotel_rate: 600,
hotel_amount: 1800,
total_allowance_rate: 120,
allowance_amount: 360,
total_amount: 2160
},
{ grade: 'P5' }
)
const message = {
id: 'application-preview-editor-message',
applicationPreview: preview,
text: ''
}
let persistCount = 0
const toastMessages = []
const editor = useApplicationPreviewEditor({
persistSessionState: () => {
persistCount += 1
},
toast: (messageText) => {
toastMessages.push(messageText)
}
})
editor.openApplicationPreviewEditor(message, 'transportMode', '待补充')
editor.applicationPreviewEditor.value.draftValue = '飞机'
const committed = await editor.commitApplicationPreviewEditor(message)
assert.equal(committed, true)
assert.equal(message.applicationPreview.fields.transportMode, '飞机')
assert.equal(message.applicationPreview.fields.transportEstimatedAmount, '2,330元')
assert.equal(message.applicationPreview.fields.amount, '4,490元')
assert.equal(message.applicationPreview.fields.transportPolicy, '预估交通费用 2,330元')
assert.doesNotMatch(message.applicationPreview.fields.transportPolicy, /参考票价|查询耗时|2026-05-25|真实票据/)
assert.doesNotMatch(message.applicationPreview.fields.transportPolicy, /模拟/)
assert.ok(persistCount >= 2)
assert.equal(toastMessages.at(-1), '已更新出行方式和费用测算。')
})
test('application preview editor recalculates days and subsidy after date range change', async () => {
const preview = normalizeApplicationPreview({
fields: {
applicationType: '\u5dee\u65c5\u8d39\u7528\u7533\u8bf7',
time: '2026-05-25',
location: '\u4e0a\u6d77',
reason: '\u670d\u52a1\u9879\u76ee\u90e8\u7f72',
days: '1\u5929',
transportMode: '\u706b\u8f66',
amount: '',
grade: 'P5',
applicant: '\u674e\u6587\u9759',
department: '\u6280\u672f\u90e8',
position: '\u8d22\u52a1\u667a\u80fd\u5316\u4ea7\u54c1\u7ecf\u7406',
managerName: '\u5411\u4e07\u7ea2'
}
})
const message = {
id: 'application-preview-editor-date-message',
applicationPreview: preview,
text: ''
}
const requestedPayloads = []
const editor = useApplicationPreviewEditor({
persistSessionState: () => {},
toast: () => {},
currentUser: ref({ grade: 'P5' }),
calculateTravelReimbursement: async (payload) => {
requestedPayloads.push(payload)
return {
days: payload.days,
location: payload.location,
matched_city: payload.location,
grade: payload.grade,
hotel_rate: 450,
hotel_amount: 1800,
total_allowance_rate: 100,
allowance_amount: 400,
total_amount: 2200,
rule_name: '\u516c\u53f8\u5dee\u65c5\u8d39\u62a5\u9500\u89c4\u5219',
rule_version: 'v1.0.0'
}
}
})
editor.openApplicationPreviewEditor(message, 'time', message.applicationPreview.fields.time)
editor.setApplicationPreviewDateMode('range')
editor.applicationPreviewEditor.value.rangeStartDate = '2026-02-20'
editor.applicationPreviewEditor.value.rangeEndDate = '2026-02-23'
const committed = await editor.commitApplicationPreviewDateEditor(message)
assert.equal(committed, true)
assert.deepEqual(requestedPayloads.at(-1), { days: 4, location: '\u4e0a\u6d77', grade: 'P5' })
assert.equal(message.applicationPreview.fields.time, '2026-02-20 \u81f3 2026-02-23')
assert.equal(message.applicationPreview.fields.days, '4\u5929')
assert.equal(message.applicationPreview.fields.lodgingDailyCap, '450\u5143/\u5929')
assert.equal(message.applicationPreview.fields.subsidyDailyCap, '100\u5143/\u5929')
assert.match(message.applicationPreview.fields.policyEstimate, /\u8865\u8d34 400\u5143/)
})