- 新增 25+ 条风险规则(预算/报销/申请/通用类),完善风险规则模拟与反馈发布机制 - 引入费用审批动态路由、平台风险分级、预审与风险阶段管理 - 预算中心列表化改造,优化票据夹仪表盘与数字员工工作看板 - 新增 Hermes 风险线索收集器、Agent 链路追踪中心 - 扩展数字员工能力库(18 个领域 Skill)与交通费用自动预估 - 完善报销申请快速预览、权限控制与前端测试覆盖
627 lines
31 KiB
JavaScript
627 lines
31 KiB
JavaScript
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 {
|
||
buildApplicationPreviewFooterMessage,
|
||
buildApplicationPreviewRows,
|
||
buildApplicationPreviewSubmitText,
|
||
buildApplicationTemplatePreview,
|
||
applyApplicationPolicyEstimateResult,
|
||
buildApplicationPolicyEstimateRequest,
|
||
buildLocalApplicationPreview,
|
||
buildLocalApplicationPreviewMessage,
|
||
buildModelRefinedApplicationPreview,
|
||
normalizeApplicationPreview,
|
||
shouldUseLocalApplicationPreview
|
||
} from '../src/utils/expenseApplicationPreview.js'
|
||
import {
|
||
buildMockApplicationTransportEstimate,
|
||
resolveMockApplicationTransportWaitMs,
|
||
buildSystemApplicationEstimate
|
||
} from '../src/utils/expenseApplicationEstimate.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('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.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.match(datedTrainEstimate.basisText, /查询耗时 \d+ms/)
|
||
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 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, /application-draft-head/)
|
||
assert.match(messageItemTemplate, /mdi mdi-file-document-check-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(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(flowScript, /application-submit-success/)
|
||
assert.match(flowScript, /function shouldHideToolCall/)
|
||
assert.match(flowScript, /semantic_ontology/)
|
||
assert.match(flowScript, /return null/)
|
||
assert.match(flowScript, /申请单提交成功/)
|
||
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('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.transportPolicy, /2026-05-25/)
|
||
assert.match(estimatedPreview.fields.transportPolicy, /查询耗时 \d+ms/)
|
||
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.match(message.applicationPreview.fields.transportPolicy, /已查询 2026-05-25 飞机参考票价/)
|
||
assert.match(message.applicationPreview.fields.transportPolicy, /查询耗时 \d+ms/)
|
||
assert.doesNotMatch(message.applicationPreview.fields.transportPolicy, /模拟/)
|
||
assert.ok(persistCount >= 2)
|
||
assert.equal(toastMessages.at(-1), '已更新出行方式和费用测算。')
|
||
})
|