Files
X-Financial/web/tests/expense-application-fast-preview.test.mjs
caoxiaozhu ee730aa31c feat(web): AI 工作台文件预览/附件关联任务与草稿分支
- 新增 WorkbenchAiFilePreviewDialog 附件预览对话框及 useWorkbenchAiFilePreview,附件支持点击预览
- 新增 attachmentAssociationJobs/linkedReimbursementDraftJobs 前端服务与对应 composable,接入后台任务轮询与状态展示
- 新增 travelReimbursementDraftBranchModel 草稿分支模型,报销关联门控支持跳过/选择草稿
- PersonalWorkbenchAiMode 及各 composable(expense/document/steward/application-preview/attachment-association)重构适配,WorkbenchAiComposer/FileStrip 样式与交互完善
- DocumentsCenter/ReceiptFolder/TravelReimbursementCreate 等视图及 scripts 重构,风险/差旅规划/审批等工具适配
- 新增/更新前端测试:application-result-card、reimbursement-list-preview-fetch、guided-flow、composer-components 等
2026-06-24 10:42:50 +08:00

2100 lines
98 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,
applicationDateRangesOverlap,
normalizeApplicationPreview,
normalizeTransportModeOption,
resolveApplicationDateRange,
resolveApplicationTimeLabel,
shouldRequireApplicationModelReview,
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 {
buildStewardSuggestedActions,
filterStewardBlockingMissingFields
} from '../src/views/scripts/stewardPlanModel.js'
import {
buildStewardFieldCompletionContinuation,
buildStewardFieldCompletionRawText
} from '../src/views/scripts/stewardFieldCompletionModel.js'
import {
shouldUseBudgetCompileReport
} from '../src/views/scripts/budgetAssistantReportModel.js'
import { resolveStewardTypewriterNextIndex } from '../src/views/scripts/stewardTypewriter.js'
import {
ASSISTANT_SCOPE_ACTION_SWITCH,
ASSISTANT_SCOPE_SESSION_APPLICATION,
ASSISTANT_SCOPE_SESSION_EXPENSE,
ASSISTANT_SCOPE_SESSION_STEWARD,
buildUnsupportedBusinessScopeConversation,
inferAssistantScopeTarget,
resolveAssistantScopeGuard
} from '../src/utils/assistantSessionScope.js'
import { useTravelReimbursementFlow } from '../src/views/scripts/useTravelReimbursementFlow.js'
import { useApplicationPreviewEditor } from '../src/views/scripts/useApplicationPreviewEditor.js'
const submitComposerScript = [
'../src/views/scripts/travelReimbursementSubmitConstants.js',
'../src/views/scripts/travelReimbursementSubmitApplicationConflicts.js',
'../src/views/scripts/travelReimbursementSubmitApplicationPreview.js',
'../src/views/scripts/travelReimbursementSubmitLocalPreviewFlow.js',
'../src/views/scripts/travelReimbursementSubmitStewardDelegation.js',
'../src/views/scripts/travelReimbursementSubmitAttachmentFlow.js',
'../src/views/scripts/travelReimbursementSubmitDraftPreflight.js',
'../src/views/scripts/travelReimbursementSubmitRecognitionFlow.js',
'../src/views/scripts/travelReimbursementSubmitResponseModel.js',
'../src/views/scripts/useTravelReimbursementSubmitComposer.js'
].map((path) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')).join('\n')
const stewardServiceScript = readFileSync(
fileURLToPath(new URL('../src/services/steward.js', import.meta.url)),
'utf8'
)
const createViewScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
'utf8'
)
const messageActionsScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementMessageActions.js', import.meta.url)),
'utf8'
)
const suggestedActionsScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSuggestedActions.js', import.meta.url)),
'utf8'
)
const stewardRuntimeScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementStewardRuntime.js', import.meta.url)),
'utf8'
)
const stewardRuntimeTextModelScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelReimbursementStewardRuntimeTextModel.js', import.meta.url)),
'utf8'
)
const stewardFollowupFlowScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelReimbursementStewardFollowupFlow.js', import.meta.url)),
'utf8'
)
const stewardPlanFlowScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useStewardPlanFlow.js', import.meta.url)),
'utf8'
)
const stewardFieldCompletionScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/stewardFieldCompletionModel.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 去上海支撑上海电力部署项目出差4天高铁预计金额2358元'
assert.equal(
shouldUseLocalApplicationPreview(prompt, {
sessionType: 'application',
attachmentCount: 0,
reviewAction: '',
systemGenerated: false
}),
true
)
assert.equal(
shouldUseLocalApplicationPreview('帮我查询申请状态', {
sessionType: 'application',
attachmentCount: 0,
reviewAction: '',
systemGenerated: false
}),
false
)
assert.equal(
shouldUseLocalApplicationPreview('小财管家\n23:04\n这是费用申请核对结果请核对', {
sessionType: 'application',
attachmentCount: 0,
reviewAction: '',
systemGenerated: false
}),
false
)
assert.equal(
shouldUseLocalApplicationPreview('我要申请', {
sessionType: 'application',
attachmentCount: 0,
reviewAction: '',
systemGenerated: false
}),
false
)
assert.equal(
shouldUseLocalApplicationPreview('去上海出差,支撑国网仿生产环境部署', {
sessionType: 'application',
attachmentCount: 0,
reviewAction: '',
systemGenerated: false
}),
true
)
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, '4天')
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('unsupported business guidance opens in assistant conversation form', () => {
const conversation = buildUnsupportedBusinessScopeConversation('你好')
assert.equal(conversation.state_json.session_type, ASSISTANT_SCOPE_SESSION_STEWARD)
assert.equal(conversation.messages.length, 1)
assert.equal(conversation.messages[0].role, 'assistant')
assert.match(conversation.messages[0].content, /小财管家暂时不处理「你好」/)
assert.equal(conversation.messages[0].assistantName, '小财管家')
assert.match(conversation.messages[0].content, /### 当前可继续的场景/)
assert.equal(
conversation.messages[0].message_json.orchestrator_payload.result.suggested_actions.length,
4
)
})
test('assistant scope guard blocks unsupported non-financial intent', () => {
const greetingGuard = resolveAssistantScopeGuard('你好', ASSISTANT_SCOPE_SESSION_APPLICATION)
const guard = resolveAssistantScopeGuard('帮我写一首诗,主题是春天', ASSISTANT_SCOPE_SESSION_APPLICATION)
assert.equal(greetingGuard.blocked, true)
assert.equal(greetingGuard.targetSessionType, '')
assert.equal(greetingGuard.suggestedActions.length, 4)
assert.deepEqual(
greetingGuard.suggestedActions.map((item) => item.action_type),
Array.from({ length: 4 }, () => ASSISTANT_SCOPE_ACTION_SWITCH)
)
assert.match(greetingGuard.text, /小财管家暂时不处理「你好」/)
assert.match(greetingGuard.text, /您可以直接点下面的场景继续/)
assert.equal(guard.suggestedActions.length, 4)
assert.equal(guard.blocked, true)
assert.equal(guard.targetSessionType, '')
assert.match(guard.text, /此意图系统不支持/)
assert.match(guard.text, /当前系统支持的业务范围/)
})
test('assistant scope guard routes related business intent instead of blocking', () => {
const guard = resolveAssistantScopeGuard('帮我查一下报销单状态', ASSISTANT_SCOPE_SESSION_APPLICATION)
assert.equal(guard.blocked, undefined)
assert.equal(guard.targetSessionType, ASSISTANT_SCOPE_SESSION_EXPENSE)
assert.match(guard.text, /报销助手/)
assert.equal(guard.suggestedActions[0].payload.session_type, ASSISTANT_SCOPE_SESSION_EXPENSE)
})
test('assistant scope guard keeps current supported application intent and steward finance queries', () => {
assert.equal(
resolveAssistantScopeGuard('申请下周去上海出差,支撑服务器部署', ASSISTANT_SCOPE_SESSION_APPLICATION),
null
)
assert.equal(inferAssistantScopeTarget('查询一下预算余额'), ASSISTANT_SCOPE_SESSION_STEWARD)
})
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, 'fallback_transport_budget_estimate_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,200')
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.equal(rows.find((row) => row.key === 'time_return')?.editable, true)
assert.match(submitText, /出发时间2026-02-20/)
assert.match(submitText, /返回时间2026-02-23/)
assert.match(submitText, /事由:支撑国网仿生产环境部署/)
assert.doesNotMatch(submitText, /发生时间:/)
})
test('application preview parses same-month shorthand date range', () => {
const preview = buildLocalApplicationPreview(
'我要申请2月20日-23日去上海出差辅助国网仿生产项目部署',
{
name: '曹笑竹',
departmentName: '技术部',
position: '财务智能化产品经理',
managerName: '向万红',
grade: 'P5'
},
{ today: '2026-06-09' }
)
const rows = buildApplicationPreviewRows(preview)
assert.equal(preview.fields.time, '2026-02-20 至 2026-02-23')
assert.equal(preview.fields.days, '4天')
assert.equal(rows.find((row) => row.key === 'time')?.value, '2026-02-20')
assert.equal(rows.find((row) => row.key === 'time_return')?.value, '2026-02-23')
assert.equal(preview.fields.location, '上海')
assert.equal(preview.fields.reason, '辅助国网仿生产项目部署')
assert.doesNotMatch(preview.fields.reason, /小财管家继续执行/)
})
test('application preview splits compact destination and business purpose', () => {
const preview = buildLocalApplicationPreview(
'2026-02-20 至 2026-02-23去上海辅助国网仿生产服务器部署火车',
{
name: '曹笑竹',
departmentName: '技术部',
position: '财务智能化产品经理',
managerName: '向万红',
grade: 'P5'
},
{ today: '2026-06-09' }
)
assert.equal(preview.fields.time, '2026-02-20 至 2026-02-23')
assert.equal(preview.fields.days, '4天')
assert.equal(preview.fields.location, '上海')
assert.equal(preview.fields.reason, '辅助国网仿生产服务器部署')
assert.equal(preview.fields.transportMode, '火车')
assert.equal(preview.readyToSubmit, true)
assert.deepEqual(preview.validationIssues, [])
})
test('application preview blocks submit when date range conflicts with explicit days', () => {
const preview = buildLocalApplicationPreview(
'申请2月20-23日去上海出差3天辅助国网仿生产服务器部署火车',
{
name: '曹笑竹',
departmentName: '技术部',
position: '财务智能化产品经理',
managerName: '向万红',
grade: 'P5'
},
{ today: '2026-06-09' }
)
const normalized = normalizeApplicationPreview(preview)
const footer = buildApplicationPreviewFooterMessage(normalized)
assert.equal(normalized.fields.time, '2026-02-20 至 2026-02-23')
assert.equal(normalized.fields.days, '3天')
assert.equal(normalized.readyToSubmit, false)
assert.equal(normalized.validationIssues[0].code, 'time_days_conflict')
assert.match(footer, /按自然日为 4 天/)
assert.match(footer, /填写的是 3 天/)
})
test('application preview blocks submit when location candidates conflict', () => {
const preview = buildLocalApplicationPreview(
'申请2月20-23日去北京出差4天地点上海辅助国网仿生产服务器部署火车',
{
name: '曹笑竹',
departmentName: '技术部',
position: '财务智能化产品经理',
managerName: '向万红',
grade: 'P5'
},
{ today: '2026-06-09' }
)
const footer = buildApplicationPreviewFooterMessage(preview)
assert.equal(preview.readyToSubmit, false)
assert.equal(preview.validationIssues[0].code, 'location_candidates_conflict')
assert.match(footer, /同时出现多个地点/)
assert.match(footer, /北京/)
assert.match(footer, /上海/)
})
test('application preview does not treat application type labels as locations', () => {
const preview = normalizeApplicationPreview({
sourceText: [
'费用申请出差',
'任务摘要:交通方式和出差预算待补充',
'申请类型:差旅费用申请',
'地点:上海',
'申请2月20日-23日火车去上海出差服务国网仿生产服务器部署'
].join('\n'),
fields: {
applicationType: '差旅费用申请',
time: '2026-02-20 至 2026-02-23',
location: '上海',
reason: '服务国网仿生产服务器部署',
days: '4天',
transportMode: '火车',
amount: '2120元',
grade: 'P5',
applicant: '曹笑竹',
department: '技术部',
position: '产品经理',
managerName: '向万红'
}
})
assert.equal(preview.readyToSubmit, true)
assert.deepEqual(preview.validationIssues, [])
assert.doesNotMatch(buildApplicationPreviewFooterMessage(preview), /多个地点|费用申请/)
})
test('application preview trusts model-refined fields over noisy source candidates', () => {
const preview = normalizeApplicationPreview({
sourceText: [
'任务摘要:交通方式和出差预算待补充',
'申请2月20日-23日火车去上海出差服务国网仿生产服务器部署'
].join('\n'),
modelRefined: true,
modelReviewStatus: 'completed',
parseStrategy: 'llm_primary',
fields: {
applicationType: '差旅费用申请',
time: '2026-02-20 至 2026-02-23',
location: '上海',
reason: '服务国网仿生产服务器部署',
days: '4天',
transportMode: '火车',
amount: '2120元',
grade: 'P5',
applicant: '曹笑竹',
department: '技术部',
position: '产品经理',
managerName: '向万红'
}
})
assert.equal(preview.readyToSubmit, true)
assert.deepEqual(preview.validationIssues, [])
})
test('application preview normalizes model-refined location mixed with business content', () => {
const rawText = '申请2月20日-23日火车出差事由辅助国网仿生产服务器部署'
const preview = buildModelRefinedApplicationPreview(
buildLocalApplicationPreview(rawText, { name: '曹笑竹', grade: 'P5' }, { today: '2026-06-09' }),
{
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: 'policy_total_amount', value: '2120元', normalized_value: '2120' }
],
time_range: {
start_date: '2026-02-20',
end_date: '2026-02-23'
},
missing_slots: []
},
rawText,
{ name: '曹笑竹', grade: 'P5' }
)
const estimateRequest = buildApplicationPolicyEstimateRequest(preview, { grade: 'P5', location: '武汉' })
const footer = buildApplicationPreviewFooterMessage(preview)
assert.equal(preview.fields.location, '上海')
assert.equal(preview.fields.reason, '辅助国网仿生产服务器部署')
assert.equal(preview.readyToSubmit, true)
assert.deepEqual(preview.validationIssues, [])
assert.match(footer, /#application-submit/)
assert.equal(estimateRequest.canCalculate, true)
assert.equal(estimateRequest.payload.location, '上海')
})
test('application preview blocks submit when transport candidates conflict', () => {
const preview = buildLocalApplicationPreview(
'申请2月20-23日去上海出差4天辅助国网仿生产服务器部署出行方式飞机坐火车',
{
name: '曹笑竹',
departmentName: '技术部',
position: '财务智能化产品经理',
managerName: '向万红',
grade: 'P5'
},
{ today: '2026-06-09' }
)
assert.equal(preview.readyToSubmit, false)
assert.equal(preview.validationIssues[0].code, 'transport_candidates_conflict')
assert.match(buildApplicationPreviewFooterMessage(preview), /同时出现多个出行方式/)
})
test('application preview normalizes compact amount candidates', () => {
const preview = buildLocalApplicationPreview(
'申请2月20-23日去上海出差4天辅助国网仿生产服务器部署火车预计费用1.8k',
{
name: '曹笑竹',
departmentName: '技术部',
position: '财务智能化产品经理',
managerName: '向万红',
grade: 'P5'
},
{ today: '2026-06-09' }
)
assert.equal(preview.fields.amount, '1800元')
assert.equal(preview.readyToSubmit, true)
assert.deepEqual(preview.validationIssues, [])
})
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 preserves ontology amount roles for travel estimates', () => {
const rawText = '申请2月20日-23日火车去上海出差服务国网仿生产服务器部署'
const localPreview = buildLocalApplicationPreview(rawText, { name: '曹笑竹', grade: 'P5' }, { today: '2026-06-13' })
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: 'transport_estimated_amount', value: '720元', normalized_value: '720' },
{ type: 'hotel_amount', value: '1000元', normalized_value: '1000' },
{ type: 'allowance_amount', value: '400元', normalized_value: '400' },
{ type: 'policy_total_amount', value: '2120元', normalized_value: '2120' }
],
time_range: {
start_date: '2026-02-20',
end_date: '2026-02-23'
},
missing_slots: []
},
rawText,
{ name: '曹笑竹', grade: 'P5' }
)
assert.equal(refinedPreview.fields.amount, '2120元')
assert.equal(refinedPreview.fields.transportEstimatedAmount, '720元')
assert.equal(refinedPreview.fields.hotelAmount, '1000元')
assert.equal(refinedPreview.fields.allowanceAmount, '400元')
assert.equal(refinedPreview.fields.policyTotalAmount, '2120元')
})
test('application preview ignores model reason polluted by application type', () => {
const rawText = '我申请2月20日至23日去上海出差辅助国网方法生产服务器上线部署'
const localPreview = buildLocalApplicationPreview(rawText, {
name: '曹笑竹',
grade: 'P5'
}, {
today: '2026-06-13'
})
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: '类型:差旅费用申请' }
],
missing_slots: []
},
rawText,
{ name: '曹笑竹', grade: 'P5' }
)
assert.equal(localPreview.fields.reason, '辅助国网方法生产服务器上线部署')
assert.equal(refinedPreview.fields.reason, '辅助国网方法生产服务器上线部署')
assert.doesNotMatch(refinedPreview.fields.reason, /类型|差旅费用申请/)
})
test('application preview strips internal steward instruction from reason', () => {
const preview = buildLocalApplicationPreview(
'申请2月20-23日去上海出差事由辅助国网仿生产服务器部署请直接生成申请单核对结果信息足够时生成申请单但在入库或提交审批前仍需让我确认',
{ name: '曹笑竹', grade: 'P5' },
{ today: '2026-06-09' }
)
assert.equal(preview.fields.reason, '辅助国网仿生产服务器部署')
assert.doesNotMatch(preview.fields.reason, /请直接生成|入库|提交审批/)
})
test('application preview requires explicit transport mode before submit', () => {
const rawText = '\u7533\u8bf7 2026-05-25 \u81f3 2026-05-27 \u53bb\u4e0a\u6d77\u51fa\u5dee3\u5929\uff0c\u670d\u52a1\u9879\u76ee\u90e8\u7f72\uff0c\u9884\u8ba1\u8d39\u75281800\u5143'
const localPreview = buildLocalApplicationPreview(rawText, {
name: '\u674e\u6587\u9759',
grade: 'P5'
})
const refinedPreview = buildModelRefinedApplicationPreview(
localPreview,
{
parse_strategy: 'llm_primary',
entities: [
{ type: 'expense_type', value: '\u5dee\u65c5\u8d39', normalized_value: 'travel' },
{ type: 'location', value: '\u4e0a\u6d77', normalized_value: '\u4e0a\u6d77' },
{ type: 'reason', value: '\u670d\u52a1\u9879\u76ee\u90e8\u7f72', normalized_value: '\u670d\u52a1\u9879\u76ee\u90e8\u7f72' },
{ type: 'transport_mode', value: '\u706b\u8f66', normalized_value: '\u706b\u8f66' },
{ type: 'amount', value: '1800\u5143', normalized_value: '1800' }
],
time_range: {
start: '2026-05-25',
end: '2026-05-27'
},
missing_slots: []
},
rawText,
{ name: '\u674e\u6587\u9759', grade: 'P5' }
)
assert.equal(localPreview.fields.transportMode, '')
assert.equal(refinedPreview.fields.transportMode, '')
assert.equal(refinedPreview.missingFields.includes('\u51fa\u884c\u65b9\u5f0f'), true)
assert.equal(refinedPreview.readyToSubmit, false)
})
test('application preview does not treat transport prompt options as selected mode', () => {
const preview = buildLocalApplicationPreview(
'当前还需要补充:出行方式。请先补充出行方式,可以选择火车、飞机或轮船。',
{ name: '李文静', grade: 'P5' }
)
const mixedPreview = buildLocalApplicationPreview(
'任务摘要:交通方式和出差预算待补充\n申请2月20日-23日火车去上海出差',
{ name: '李文静', grade: 'P5' },
{ today: '2026-06-09' }
)
assert.equal(preview.fields.transportMode, '')
assert.equal(mixedPreview.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 去上海支撑服务器部署出差4天火车预计费用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.match(submitComposerScript, /function resetStewardDelegatedInsightState\(\) \{[\s\S]*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(messageActionsScript, /message-bubble-application-preview/)
assert.match(messageActionsScript, /buildApplicationPreviewFooterMessage/)
assert.match(messageActionsScript, /function buildApplicationPreviewFooterText\(message\)/)
assert.match(stewardRuntimeScript, /buildApplicationPreviewSubmitText/)
assert.match(stewardRuntimeScript, /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.doesNotMatch(messageItemTemplate, /application-preview-date-chip/)
assert.match(messageItemTemplate, /申请单据已生成/)
assert.match(messageItemTemplate, /ui\.shouldShowDraftSavedCard\(message\)/)
assert.match(messageItemTemplate, /报销草稿已生成/)
assert.match(messageItemTemplate, /报销草稿待保存/)
assert.match(messageItemTemplate, /ui\.resolveReimbursementDraftClaimNo\(message\.draftPayload\)/)
assert.match(messageItemTemplate, /v-if="ui\.canOpenDraftDetail\(message\)"/)
assert.match(messageItemTemplate, /class="reimbursement-draft-link"/)
assert.match(messageItemTemplate, /查看详情/)
assert.match(messageItemTemplate, /class="reimbursement-draft-pending-detail"/)
assert.match(messageItemTemplate, /保存后可查看详情/)
assert.match(messageActionsScript, /function canOpenDraftDetail\(message\)/)
assert.match(createViewScript, /canOpenDraftDetail,/)
assert.match(messageActionsScript, /保存后生成/)
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.doesNotMatch(messageItemTemplate, /<OperationFeedbackInlineCard/)
assert.match(messageItemTemplate, /class="message-action-toolbar"/)
assert.match(messageItemTemplate, /ui\.shouldShowAssistantMessageActions\(message\)/)
assert.match(messageItemTemplate, /ui\.copyAssistantMessage\(message\)/)
assert.match(messageItemTemplate, /ui\.speakAssistantMessage\(message\)/)
assert.match(messageItemTemplate, /ui\.submitOperationFeedbackForMessage\(message, \{ rating: 5, reason: 'thumbs_up' \}\)/)
assert.match(messageItemTemplate, /ui\.submitOperationFeedbackForMessage\(message, \{ rating: 1, reason: 'thumbs_down' \}\)/)
assert.match(messageItemTemplate, /mdi mdi-content-copy/)
assert.match(messageItemTemplate, /mdi mdi-volume-high/)
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(messageActionsScript, /function shouldShowAssistantMessageActions/)
assert.match(messageActionsScript, /function buildMessageOperationFeedbackContext/)
assert.match(messageActionsScript, /function isMessageFeedbackSelected/)
assert.match(messageActionsScript, /function submitOperationFeedbackForMessage/)
assert.match(stewardRuntimeScript, /const stewardSubmitContinuation = message\?\.stewardContinuation \|\| null/)
assert.match(stewardRuntimeScript, /stewardContinuation:\s*stewardSubmitContinuation/)
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 \(!stewardDelegated && isApplicationSubmitOperation\) \{[\s\S]*startFlowStep\('application-submit-success'/)
assert.match(submitComposerScript, /else if \(!stewardDelegated && rawText && !reviewAction\) \{[\s\S]*startFlowStep\('intent'/)
assert.match(submitComposerScript, /if \(!isApplicationSubmitOperation && !stewardDelegated\) \{[\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('steward application missing transport blocks preview table', () => {
assert.match(submitComposerScript, /function shouldPauseStewardApplicationPreview/)
assert.match(submitComposerScript, /function sanitizeStewardDelegatedTaskSummary/)
assert.match(submitComposerScript, /交通方式和\(\?:预算\|预计\)\?金额待补充/)
assert.match(submitComposerScript, /出差费用预算/)
assert.match(submitComposerScript, /预估\|预计\|预算\)\?费用/)
assert.match(submitComposerScript, /applicationPreview:\s*pauseForMissingFields \? null : applicationPreview/)
assert.match(submitComposerScript, /我已经识别出这一步要先处理申请单,但现在还不能生成可提交的申请核对表/)
assert.match(submitComposerScript, /applicationPreview:\s*normalized/)
assert.doesNotMatch(submitComposerScript, /请先告诉我您打算怎么出行:\*\*火车、飞机或轮船\*\*/)
assert.match(suggestedActionsScript, /payload\.applicationPreview/)
assert.match(suggestedActionsScript, /function continueStewardApplicationFieldCompletion/)
assert.match(suggestedActionsScript, /submitComposerInternal\(\{[\s\S]*stewardContinuation: continuation/)
assert.match(suggestedActionsScript, /skipUserMessage:\s*true/)
assert.match(suggestedActionsScript, /targetMessage\.applicationPreview = normalizeApplicationPreview\(sourcePreview\)/)
assert.match(suggestedActionsScript, /openApplicationPreviewEditor\(targetMessage, fieldKey/)
assert.match(suggestedActionsScript, /commitApplicationPreviewEditor\(targetMessage\)/)
assert.match(stewardFieldCompletionScript, /transportMode:\s*'transport_mode'/)
assert.match(stewardFieldCompletionScript, /基础规则交通费用预估表/)
})
test('steward field completion reruns application preview instead of directly rendering table', () => {
const continuation = {
planId: 'steward-plan-transport-gap',
currentTaskId: 'task-application-beijing',
currentTask: {
task_id: 'task-application-beijing',
task_type: 'expense_application',
summary: '明天前往北京出差3天支撑国网仿生产部署',
ontology_fields: {
time_range: '2026-06-05 至 2026-06-07',
location: '北京',
reason: '支撑国网仿生产部署'
},
missing_fields: ['transport_mode']
},
remainingTasks: []
}
const preview = normalizeApplicationPreview({
fields: {
applicationType: '差旅费用申请',
time: '2026-06-05 至 2026-06-07',
location: '北京',
reason: '支撑国网仿生产部署',
days: '3天',
transportMode: ''
}
})
const nextContinuation = buildStewardFieldCompletionContinuation(continuation, 'transportMode', '火车')
assert.equal(nextContinuation.currentTask.ontology_fields.transport_mode, '火车')
assert.deepEqual(nextContinuation.currentTask.missing_fields, [])
const carryText = buildStewardFieldCompletionRawText({
preview,
fieldKey: 'transportMode',
fieldLabel: '出行方式',
value: '火车',
continuation: nextContinuation
})
assert.match(carryText, /用户已补充:出行方式:火车/)
assert.match(carryText, /地点:北京/)
assert.match(carryText, /天数3天/)
assert.match(carryText, /请先根据已补齐字段按基础规则交通费用预估表/)
const rebuiltPreview = buildLocalApplicationPreview(carryText, { name: '曹笑竹', grade: 'P5' })
assert.equal(rebuiltPreview.fields.location, '北京')
assert.equal(rebuiltPreview.fields.transportMode, '火车')
assert.equal(rebuiltPreview.fields.days, '3天')
})
test('budget compile report does not steal steward delegated application rerun', () => {
const staleBudgetContext = {
budgetNo: 'BUD-2026-TECH',
mode: 'edit',
categoryRows: []
}
const stewardApplicationText = [
'小财管家继续执行申请单字段补齐。',
'用户已补充:出行方式:火车。',
'地点:北京',
'天数3天',
'处理要求:请先根据已补齐字段按基础规则交通费用预估表测算费用口径,完成系统预估金额测算,再生成申请单核对表。'
].join('\n')
assert.equal(shouldUseBudgetCompileReport(stewardApplicationText, {
sessionType: 'application',
entrySource: 'workbench',
budgetContext: staleBudgetContext
}), false)
assert.equal(shouldUseBudgetCompileReport('帮我生成 2026 年 Q3 预算编制建议', {
sessionType: 'budget',
entrySource: 'budget',
budgetContext: staleBudgetContext
}), true)
assert.match(submitComposerScript, /if \(!stewardDelegated && shouldUseBudgetCompileReport/)
})
test('text confirmation submits pending application preview before replanning steward task', () => {
assert.match(stewardServiceScript, /fetchStewardRuntimeDecision/)
assert.match(stewardServiceScript, /\/steward\/runtime-decisions/)
assert.match(stewardRuntimeScript, /function buildStewardRuntimeState/)
assert.match(stewardRuntimeScript, /function buildStewardRuntimeFastPathDecision/)
assert.match(stewardRuntimeScript, /function shouldUseStewardRuntimeLlmDecision/)
assert.match(stewardRuntimeScript, /function findPendingSlotSuggestedActionContextByInput/)
assert.match(stewardRuntimeTextModelScript, /function shouldPlanNewStewardTasksLocally/)
assert.match(stewardRuntimeTextModelScript, /function resolveStewardRuntimeTransportAlias/)
assert.match(stewardRuntimeScript, /const actionTransportAlias = resolveStewardRuntimeTransportAlias/)
assert.match(stewardRuntimeScript, /actionTransportAlias === transportAlias/)
assert.match(stewardRuntimeScript, /next_action:\s*'continue_next_task'/)
assert.match(stewardRuntimeScript, /next_action:\s*'submit_current_application'/)
assert.match(stewardRuntimeScript, /next_action:\s*'fill_current_slot'/)
assert.match(stewardRuntimeScript, /next_action:\s*'plan_new_tasks'/)
assert.match(stewardRuntimeScript, /suppressUserEcho:\s*userMessageAlreadyAdded/)
assert.match(suggestedActionsScript, /if \(!action\?\.suppressUserEcho\) \{[\s\S]*messages\.value\.push\(createMessage\('user', userText\)\)/)
assert.match(suggestedActionsScript, /skipApplicationModelReview:\s*true/)
assert.match(suggestedActionsScript, /skipApplicationModelReview:\s*targetSessionType === SESSION_TYPE_APPLICATION/)
assert.match(suggestedActionsScript, /skipStewardSlotDecision:\s*targetSessionType === SESSION_TYPE_APPLICATION/)
assert.match(submitComposerScript, /skipModelReview:\s*Boolean\(stewardDelegated && options\.skipApplicationModelReview\)/)
assert.match(submitComposerScript, /const requireModelReview = shouldRequireApplicationModelReview\(rawText\)/)
assert.match(submitComposerScript, /if \(options\.skipModelReview && !requireModelReview\) \{[\s\S]*结构化快路径/)
assert.match(submitComposerScript, /const localPauseForMissingFields = shouldPauseStewardApplicationPreview\(applicationPreview\)/)
assert.match(submitComposerScript, /const shouldFetchSlotDecision = localPauseForMissingFields && !options\.skipStewardSlotDecision/)
assert.match(submitComposerScript, /const slotDecision = shouldFetchSlotDecision[\s\S]*fetchStewardApplicationSlotDecision/)
assert.match(submitComposerScript, /const pendingSuggestedActions = Array\.isArray\(finalExtras\.suggestedActions\)/)
assert.match(submitComposerScript, /message\.suggestedActions = pendingSuggestedActions[\s\S]*message\.stewardPlan = buildStewardDelegatedPlan\(continuation, \[\.\.\.typedEvents\], 'typing'\)/)
assert.match(stewardRuntimeScript, /async function handleStewardRuntimeDecision/)
assert.match(stewardRuntimeScript, /const runtimeState = buildStewardRuntimeState\(\)/)
assert.match(stewardRuntimeScript, /if \(!hasActiveStewardRuntimeDecisionContext\(runtimeState\)\) \{[\s\S]*return false/)
assert.match(stewardRuntimeScript, /function pushStewardRuntimeUserMessage\(userText = ''\)/)
assert.match(stewardRuntimeScript, /const userMessageAlreadyAdded = options\.skipUserMessage[\s\S]*pushStewardRuntimeUserMessage\(rawText\)/)
assert.match(stewardRuntimeScript, /const fastDecision = buildStewardRuntimeFastPathDecision\(rawText, runtimeState\)[\s\S]*submitStewardPlan\(\{[\s\S]*skipUserMessage: userMessageAlreadyAdded \|\| options\.skipUserMessage/)
assert.match(stewardRuntimeScript, /executeStewardRuntimeDecision\(fastDecision, rawText, \{ userMessageAlreadyAdded \}\)[\s\S]*if \(!shouldUseStewardRuntimeLlmDecision\(rawText, runtimeState\)\)/)
assert.match(stewardRuntimeScript, /if \(!shouldUseStewardRuntimeLlmDecision\(rawText, runtimeState\)\)[\s\S]*fetchStewardRuntimeDecision/)
assert.match(stewardRuntimeScript, /executeStewardRuntimeDecision\(decision, rawText, \{ userMessageAlreadyAdded \}\)/)
assert.match(stewardRuntimeScript, /skipUserMessage: userMessageAlreadyAdded \|\| options\.skipUserMessage/)
assert.match(stewardRuntimeScript, /fetchStewardRuntimeDecision\(\{[\s\S]*runtime_state: runtimeState/)
assert.match(createViewScript, /if \(await handleStewardRuntimeDecision\(options\)\) \{[\s\S]*return null/)
assert.match(stewardRuntimeTextModelScript, /function isApplicationSubmitConfirmationText/)
assert.match(stewardRuntimeTextModelScript, /APPLICATION_SUBMIT_CONFIRM_TEXT_PATTERN[\s\S]*确认提交[\s\S]*提交审批/)
assert.match(stewardRuntimeScript, /function findPendingApplicationSubmitMessage/)
assert.match(stewardRuntimeScript, /normalizedPreview\.readyToSubmit/)
assert.match(stewardRuntimeScript, /async function handleApplicationSubmitConfirmationText/)
assert.match(stewardRuntimeScript, /await confirmApplicationSubmit\(\{ userText: rawText \}\)/)
assert.match(createViewScript, /if \(await handleApplicationSubmitConfirmationText\(options\)\) \{[\s\S]*return null[\s\S]*\}[\s\S]*if \(isStewardSession\.value && !options\.skipStewardPlan/)
assert.match(stewardRuntimeScript, /message\.applicationSubmitConfirmed = true/)
assert.match(stewardRuntimeScript, /message\.applicationSubmitConfirmed[\s\S]*continue/)
})
test('application submit result does not render reimbursement review followup', () => {
assert.match(submitComposerScript, /function shouldExposeReviewPayloadForMessage\(payload, options = \{\}\)/)
assert.match(submitComposerScript, /options\.isApplicationSubmitOperation \|\| isApplicationDraftPayload\(result\.draft_payload\)/)
assert.match(submitComposerScript, /function buildPresentationPayload\(payload, \{ exposeReviewPayload = true \} = \{\}\)/)
assert.match(submitComposerScript, /review_payload:\s*null/)
assert.match(submitComposerScript, /const exposeReviewPayload = shouldExposeReviewPayloadForMessage\(payload, \{ isApplicationSubmitOperation \}\)/)
assert.match(submitComposerScript, /const presentationPayload = buildPresentationPayload\(payload, \{ exposeReviewPayload \}\)/)
assert.match(submitComposerScript, /const resultReviewPayload = presentationResult\.review_payload \|\| null/)
assert.match(submitComposerScript, /suggestedActions:\s*resultSuggestedActions/)
assert.match(submitComposerScript, /reviewPayload:\s*resultReviewPayload/)
assert.match(submitComposerScript, /buildAgentInsight\(\s*presentationPayload,/)
})
test('steward streaming uses chunked typewriter to reduce perceived latency', () => {
assert.match(stewardPlanFlowScript, /STEWARD_THINKING_TYPEWRITER_CHUNK_SIZE = 5/)
assert.match(stewardPlanFlowScript, /resolveStewardTypewriterNextIndex\(chars, index, STEWARD_THINKING_TYPEWRITER_CHUNK_SIZE\)/)
assert.match(submitComposerScript, /STEWARD_DELEGATED_THINKING_CHUNK_SIZE = 5/)
assert.match(submitComposerScript, /resolveStewardTypewriterNextIndex\(chars, index, STEWARD_DELEGATED_THINKING_CHUNK_SIZE\)/)
assert.match(stewardFollowupFlowScript, /STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE = 5/)
assert.match(stewardFollowupFlowScript, /resolveStewardTypewriterNextIndex\(chars, index, STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE\)/)
})
test('steward typewriter renders markdown table blocks at once', () => {
const tableText = '这是费用申请核对结果:\n| 字段 | 值 |\n| --- | --- |\n| 地点 | 上海 |\n下一段'
const tableChars = Array.from(tableText)
const tableIndex = tableText.indexOf('| 字段')
const nextParagraphIndex = tableText.indexOf('下一段')
const normalIndex = 0
assert.equal(resolveStewardTypewriterNextIndex(tableChars, normalIndex), 3)
assert.equal(resolveStewardTypewriterNextIndex(tableChars, tableIndex), nextParagraphIndex)
assert.equal(resolveStewardTypewriterNextIndex(tableChars, tableIndex - 1), nextParagraphIndex)
assert.equal(resolveStewardTypewriterNextIndex(Array.from('### 核对结果'), 0), 2)
})
test('application preview table appears as a whole card instead of row-by-row animation', () => {
assert.doesNotMatch(
messageItemStyles,
/structured-card-reveal-enter-active\s+\.application-preview-row\s*\{[\s\S]*animation:/,
)
assert.doesNotMatch(
messageItemStyles,
/application-preview-row:nth-child\([^)]*\)\s*\{[\s\S]*animation-delay:/,
)
})
test('complex travel application sentences require model review', () => {
assert.equal(
shouldRequireApplicationModelReview('申请2月20日-23日火车去上海出差服务国网仿生产服务器部署'),
true
)
assert.equal(shouldRequireApplicationModelReview('我想发起一笔费用申请'), false)
})
test('steward initial workbench entry shows recognition state before messages arrive', () => {
assert.match(createViewScript, /const hasStewardInitialAutoSubmitPayload = computed/)
assert.match(createViewScript, /const showStewardInitialRecognition = computed/)
assert.match(createViewScript, /!messages\.value\.length/)
assert.match(createViewScript, /workbenchVisible\.value \|\| submitting\.value/)
assert.match(createViewScript, /showStewardInitialRecognition/)
assert.match(createViewTemplate, /v-if="showStewardInitialRecognition"/)
assert.match(createViewTemplate, /class="steward-initial-recognition"/)
assert.match(createViewTemplate, /小财管家正在识别意图/)
})
test('steward application carry text does not leak transport examples into extraction', () => {
const actions = buildStewardSuggestedActions({
plan_id: 'steward-plan-transport-gap',
plan_status: 'ready',
tasks: [
{
task_id: 'task-application-beijing',
task_type: 'expense_application',
title: '北京出差申请',
summary: '明天前往北京出差3天支撑国网仿生产部署',
assigned_agent: 'application_assistant',
ontology_fields: {
expense_type: 'travel',
time_range: '2026-06-05 至 2026-06-07',
location: '北京',
reason: '支撑国网仿生产部署'
},
missing_fields: ['transport_mode', 'amount', 'attachments', 'employee_no']
}
],
confirmation_groups: [
{
action_type: 'confirm_create_application',
target_task_id: 'task-application-beijing'
}
]
})
const carryText = actions[0]?.payload?.carry_text || ''
const currentTask = actions[0]?.payload?.steward_current_task || null
assert.match(carryText, /费用类型:差旅/)
assert.doesNotMatch(carryText, /费用类型travel/)
assert.match(carryText, /还需要补充:出行方式/)
assert.doesNotMatch(carryText, /请先追问上述缺失信息/)
assert.doesNotMatch(carryText, /请直接生成申请单核对结果/)
assert.doesNotMatch(carryText, /入库或提交审批前/)
assert.doesNotMatch(carryText, /高铁|火车|飞机|轮船|自驾|出租车/)
assert.doesNotMatch(carryText, /预计金额|附件\/凭证|员工编号|金额/)
assert.equal(currentTask?.task_type, 'expense_application')
assert.deepEqual(currentTask?.missing_fields, ['transport_mode'])
assert.deepEqual(
filterStewardBlockingMissingFields(
['transport_type', 'amount', 'attachments', 'employee_no', 'department_name'],
'expense_application'
),
['transport_mode']
)
assert.deepEqual(
filterStewardBlockingMissingFields(['amount', 'attachments'], 'reimbursement'),
['amount', 'attachments']
)
const preview = buildLocalApplicationPreview(carryText, { name: '曹笑竹', grade: 'P5' })
assert.equal(preview.fields.transportMode, '')
assert.equal(preview.missingFields.includes('出行方式'), true)
assert.match(stewardServiceScript, /fetchStewardSlotDecision/)
assert.match(stewardServiceScript, /\/steward\/slot-decisions/)
assert.match(submitComposerScript, /fetchStewardApplicationSlotDecision/)
assert.match(submitComposerScript, /task_type:\s*'expense_application'/)
assert.match(submitComposerScript, /steward_continuation:\s*continuation/)
assert.match(suggestedActionsScript, /currentTask:\s*actionPayload\.steward_current_task/)
})
test('steward application slot fallback ignores non-blocking application fields', () => {
assert.match(submitComposerScript, /APPLICATION_NON_BLOCKING_ONTOLOGY_FIELDS/)
assert.match(submitComposerScript, /'attachments'/)
assert.match(submitComposerScript, /'employee_no'/)
assert.match(submitComposerScript, /'amount'/)
assert.match(submitComposerScript, /function formatStewardDecisionUserText/)
assert.match(submitComposerScript, /formatStewardDecisionUserText\(decision\.question/)
assert.match(submitComposerScript, /formatStewardDecisionUserText\(decision\.rationale/)
assert.match(submitComposerScript, /normalizeTransportModeOption\(value \|\| label, ''\)/)
assert.match(suggestedActionsScript, /normalizeTransportModeOption\(value, ''\)/)
assert.equal(normalizeTransportModeOption('高铁', ''), '火车')
assert.equal(normalizeTransportModeOption('自驾', ''), '')
assert.match(submitComposerScript, /function resolveBlockingApplicationMissingFieldsForSteward/)
assert.match(submitComposerScript, /isBlockingApplicationOntologyField\(key\)/)
assert.match(submitComposerScript, /canonicalField && !isBlockingApplicationOntologyField\(canonicalField\)/)
assert.doesNotMatch(submitComposerScript, /附件\/凭证和员工编号为合规必需字段/)
})
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: 560px;[\s\S]*table-layout: fixed;/)
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(th\),[\s\S]*\.message-answer-markdown :deep\(td\) \{[\s\S]*padding: 8px 10px;[\s\S]*overflow-wrap: break-word;/)
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(th:first-child\),[\s\S]*\.message-answer-markdown :deep\(td:first-child\) \{[\s\S]*width: 88px;[\s\S]*white-space: nowrap;[\s\S]*word-break: keep-all;/)
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(th:last-child\),[\s\S]*\.message-answer-markdown :deep\(td:last-child\) \{[\s\S]*width: 112px;[\s\S]*text-align: right;[\s\S]*white-space: nowrap;[\s\S]*word-break: keep-all;/)
})
test('assistant reimbursement recognition copy renders structured markdown sections', () => {
const rendered = renderMarkdown([
'识别到您希望报销一笔“业务招待费”费用:',
'',
'基础信息识别结果:',
'时间2026-06-04',
'事由:小财管家继续执行剩余任务,请填写报销单:客户接待费用报销。',
'',
'报销测算参考:',
'先以用户填写金额或票据识别金额为基础,再结合费用类型、发生地点、业务事由和规则中心限额进行复核。'
].join('\n'))
assert.match(rendered, /<h3>基础信息识别结果<\/h3>/)
assert.match(rendered, /<li><strong>时间<\/strong>2026-06-04<\/li>/)
assert.match(rendered, /<li><strong>事由<\/strong>:小财管家继续执行剩余任务/)
assert.match(rendered, /<h3>报销测算参考<\/h3>/)
assert.doesNotMatch(rendered, /基础信息识别结果:<\/h3>/)
})
test('application date overlap blocks steward preview before duplicate application table', () => {
const existingRange = resolveApplicationDateRange('2026-06-05 至 2026-06-07')
const currentRange = resolveApplicationDateRange('2026-06-06 至 2026-06-08')
const disjointRange = resolveApplicationDateRange('2026-06-08 至 2026-06-10')
assert.equal(applicationDateRangesOverlap(currentRange, existingRange), true)
assert.equal(applicationDateRangesOverlap(disjointRange, existingRange), false)
assert.match(submitComposerScript, /function findOverlappingApplicationClaim\(applicationPreview, claimsPayload\)/)
assert.match(submitComposerScript, /function normalizeApplicationExpenseType\(value\)/)
assert.match(submitComposerScript, /currentExpenseType !== existingExpenseType/)
assert.match(submitComposerScript, /fetchExpenseClaims\(\{ page: 1, pageSize: 100 \}\)/)
assert.match(submitComposerScript, /buildApplicationDateConflictMessage\(applicationDateConflict\)/)
assert.match(submitComposerScript, /meta: \[STEWARD_ASSISTANT_NAME, '申请日期冲突'\]/)
assert.match(submitComposerScript, /applicationPreview: pauseForMissingFields \? null : applicationPreview/)
assert.match(suggestedActionsScript, /actionType === 'open_application_detail'/)
})
test('application preview merges rule center travel estimate into highlighted rows', () => {
const preview = buildLocalApplicationPreview('申请 2026-05-25 至 2026-05-27 去上海出差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',
transport_mode: '火车',
origin_location: null,
travel_date: '2026-05-25'
})
const estimatedPreview = applyApplicationPolicyEstimateResult(preview, {
days: 3,
location: '上海',
matched_city: '上海',
grade: 'P5',
hotel_rate: 600,
hotel_amount: 1800,
total_allowance_rate: 120,
allowance_amount: 360,
transport_mode: '火车',
transport_origin: '武汉',
transport_destination: '上海',
transport_estimated_amount: 720,
transport_estimate_basis: '武汉-上海火车往返二等座预估',
transport_estimate_source: 'basic_rule_transport_estimate',
transport_estimate_confidence: '基础规则',
total_amount: 2880,
rule_name: '公司差旅费报销规则',
rule_version: '2026版'
}, { grade: 'P5' })
assert.equal(estimatedPreview.fields.lodgingDailyCap, '600元/天')
assert.equal(estimatedPreview.fields.subsidyDailyCap, '120元/天')
assert.equal(estimatedPreview.fields.transportPolicy, '当前尚未接通实时票务价格查询 API无法获取当前实际票价先按《交通费用预估表》武汉-上海火车往返(二等座预估)暂估 720元用于申请阶段预算占用最终报销以实际票据金额为准')
assert.doesNotMatch(estimatedPreview.fields.transportPolicy, /参考票价|查询耗时|2026-05-25|真实票据/)
assert.match(estimatedPreview.fields.policyEstimate, /交通 720元/)
assert.match(estimatedPreview.fields.policyEstimate, /2,880元/)
assert.equal(estimatedPreview.fields.transportEstimatedAmount, '720元')
assert.equal(estimatedPreview.fields.transportEstimateSource, 'basic_rule_transport_estimate')
assert.equal(estimatedPreview.fields.transportQueryLatencyMs, '')
assert.equal(estimatedPreview.fields.amount, '2,880元')
assert.equal(buildApplicationPreviewRows(estimatedPreview).find((row) => row.key === 'policyEstimate')?.highlight, true)
})
test('application preview calculates base policy estimate when transport mode is missing', () => {
const currentUser = { name: '李文静', grade: 'P5', location: '武汉' }
const preview = buildLocalApplicationPreview(
'我要申请2月20日-23日去上海出差辅助国网仿生产项目部署',
currentUser,
{ today: '2026-06-09' }
)
const request = buildApplicationPolicyEstimateRequest(preview, currentUser)
assert.equal(request.canCalculate, true)
assert.deepEqual(request.payload, {
days: 4,
location: '上海',
grade: 'P5',
transport_mode: null,
origin_location: '武汉',
travel_date: '2026-02-20'
})
assert.equal(preview.missingFields.includes('出行方式'), true)
assert.equal(preview.readyToSubmit, false)
const staleEstimateResult = {
days: 4,
location: '上海',
matched_city: '上海',
grade: 'P5',
hotel_rate: 250,
hotel_amount: 1000,
total_allowance_rate: 100,
allowance_amount: 400,
transport_mode: '火车',
transport_origin: '武汉',
transport_destination: '上海',
transport_estimated_amount: 720,
transport_estimate_basis: '武汉-上海火车往返二等座预估',
transport_estimate_source: 'basic_rule_transport_estimate',
transport_estimate_confidence: '基础规则',
total_amount: 2120,
travel_date: '2026-02-20',
rule_name: '差旅住宿报销标准',
rule_version: 'v1.0.0'
}
const blockedEstimatePreview = applyApplicationPolicyEstimateResult(preview, {
...staleEstimateResult,
transport_mode: ''
}, currentUser)
const staleEstimatePreview = applyApplicationPolicyEstimateResult(preview, staleEstimateResult, currentUser)
assert.equal(blockedEstimatePreview.fields.transportMode, '')
assert.equal(blockedEstimatePreview.fields.transportEstimatedAmount, '')
assert.equal(blockedEstimatePreview.fields.lodgingDailyCap, '250元/天')
assert.equal(blockedEstimatePreview.fields.subsidyDailyCap, '100元/天')
assert.equal(blockedEstimatePreview.fields.policyEstimate, '交通待补充 + 住宿 1,000元 + 补贴 400元 = 1,400元4天不含交通')
assert.equal(blockedEstimatePreview.fields.amount, '1,400元不含交通')
assert.equal(blockedEstimatePreview.missingFields.includes('出行方式'), true)
assert.equal(staleEstimatePreview.fields.reason, '辅助国网仿生产项目部署')
assert.equal(staleEstimatePreview.fields.transportMode, '')
assert.equal(staleEstimatePreview.missingFields.includes('出行方式'), true)
assert.equal(staleEstimatePreview.fields.transportPolicy, '选择火车、飞机或轮船后自动预估交通费用')
assert.equal(staleEstimatePreview.fields.policyEstimate, '交通待补充 + 住宿 1,000元 + 补贴 400元 = 1,400元4天不含交通')
assert.equal(staleEstimatePreview.fields.amount, '1,400元不含交通')
})
test('application preview estimate infers days from completed date range', () => {
const currentUser = { name: '\u674e\u6587\u9759', grade: 'P5', location: '\u6b66\u6c49' }
const preview = normalizeApplicationPreview({
fields: {
applicationType: '\u5dee\u65c5\u8d39\u7528\u7533\u8bf7',
time: '2026-06-23 \u81f3 2026-06-25',
location: '\u5317\u4eac',
reason: '\u652f\u6491\u5ba2\u6237\u73b0\u573a\u5b9e\u65bd',
days: '',
transportMode: '',
grade: 'P5'
}
})
const request = buildApplicationPolicyEstimateRequest(preview, currentUser)
assert.equal(request.canCalculate, true)
assert.deepEqual(request.payload, {
days: 3,
location: '\u5317\u4eac',
grade: 'P5',
transport_mode: null,
origin_location: '\u6b66\u6c49',
travel_date: '2026-06-23'
})
const estimatedPreview = applyApplicationPolicyEstimateResult(preview, {
days: 3,
location: '\u5317\u4eac',
matched_city: '\u5317\u4eac',
grade: 'P5',
hotel_rate: 450,
hotel_amount: 1350,
total_allowance_rate: 100,
allowance_amount: 300,
total_amount: 1650,
rule_name: '\u516c\u53f8\u5dee\u65c5\u8d39\u62a5\u9500\u89c4\u5219',
rule_version: 'v1.0.0'
}, currentUser)
assert.equal(estimatedPreview.fields.days, '3\u5929')
assert.equal(estimatedPreview.fields.lodgingDailyCap, '450\u5143/\u5929')
assert.equal(estimatedPreview.fields.subsidyDailyCap, '100\u5143/\u5929')
assert.equal(estimatedPreview.fields.amount, '1,650\u5143\uff08\u4e0d\u542b\u4ea4\u901a\uff09')
assert.match(estimatedPreview.fields.policyEstimate, /\u4ea4\u901a\u5f85\u8865\u5145/)
})
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, '1,380元')
assert.equal(message.applicationPreview.fields.amount, '3,540元')
assert.equal(message.applicationPreview.fields.transportPolicy, '预估交通费用 1,380元')
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',
transport_mode: '\u706b\u8f66',
origin_location: null,
travel_date: '2026-02-20'
})
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/)
})
test('application preview editor can edit return date from table row', async () => {
const preview = normalizeApplicationPreview({
fields: {
applicationType: '\u5dee\u65c5\u8d39\u7528\u7533\u8bf7',
time: '2026-02-20 \u81f3 2026-02-23',
location: '\u4e0a\u6d77',
reason: '\u670d\u52a1\u9879\u76ee\u90e8\u7f72',
days: '4\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-return-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: 2250,
total_allowance_rate: 100,
allowance_amount: 500,
total_amount: 2750,
rule_name: '\u516c\u53f8\u5dee\u65c5\u8d39\u62a5\u9500\u89c4\u5219',
rule_version: 'v1.0.0'
}
}
})
editor.openApplicationPreviewEditor(message, 'time_return', '2026-02-23')
assert.equal(editor.resolveApplicationPreviewEditorControl('time_return'), 'date')
assert.equal(editor.applicationPreviewEditor.value.dateMode, 'range')
assert.equal(editor.applicationPreviewEditor.value.rangeStartDate, '2026-02-20')
editor.applicationPreviewEditor.value.rangeEndDate = '2026-02-24'
const committed = await editor.commitApplicationPreviewDateEditor(message)
assert.equal(committed, true)
assert.deepEqual(requestedPayloads.at(-1), {
days: 5,
location: '\u4e0a\u6d77',
grade: 'P5',
transport_mode: '\u706b\u8f66',
origin_location: null,
travel_date: '2026-02-20'
})
assert.equal(message.applicationPreview.fields.time, '2026-02-20 \u81f3 2026-02-24')
assert.equal(message.applicationPreview.fields.days, '5\u5929')
})
test('application preview editor can edit return date from inline table input', async () => {
const preview = normalizeApplicationPreview({
fields: {
applicationType: '\u5dee\u65c5\u8d39\u7528\u7533\u8bf7',
time: '2026-02-20 \u81f3 2026-02-23',
location: '\u4e0a\u6d77',
reason: '\u670d\u52a1\u9879\u76ee\u90e8\u7f72',
days: '4\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-inline-return-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: 2250,
total_allowance_rate: 100,
allowance_amount: 500,
total_amount: 2750,
rule_name: '\u516c\u53f8\u5dee\u65c5\u8d39\u62a5\u9500\u89c4\u5219',
rule_version: 'v1.0.0'
}
}
})
editor.openApplicationPreviewEditor(message, 'time_return', '2026-02-23')
editor.applicationPreviewEditor.value.draftValue = '2026-02-24'
const committed = await editor.commitApplicationPreviewEditor(message)
assert.equal(committed, true)
assert.deepEqual(requestedPayloads.at(-1), {
days: 5,
location: '\u4e0a\u6d77',
grade: 'P5',
transport_mode: '\u706b\u8f66',
origin_location: null,
travel_date: '2026-02-20'
})
assert.equal(message.applicationPreview.fields.time, '2026-02-20 \u81f3 2026-02-24')
assert.equal(message.applicationPreview.fields.time_return, undefined)
assert.equal(message.applicationPreview.fields.days, '5\u5929')
})
test('application preview editor opens date fields with native date input values', () => {
const preview = normalizeApplicationPreview({
fields: {
applicationType: '\u5dee\u65c5\u8d39\u7528\u7533\u8bf7',
time: '2026-02-20 \u81f3 2026-02-23',
location: '\u4e0a\u6d77',
reason: '\u670d\u52a1\u9879\u76ee\u90e8\u7f72',
days: '4\u5929'
}
})
const message = {
id: 'application-preview-editor-native-date-message',
applicationPreview: preview,
text: ''
}
const editor = useApplicationPreviewEditor({
persistSessionState: () => {},
toast: () => {}
})
editor.openApplicationPreviewEditor(message, 'time', message.applicationPreview.fields.time)
assert.equal(editor.resolveApplicationPreviewEditorControl('time'), 'date')
assert.equal(editor.applicationPreviewEditor.value.draftValue, '2026-02-20')
assert.equal(editor.resolveApplicationPreviewEditorDateMax(message, 'time'), '2026-02-23')
editor.openApplicationPreviewEditor(message, 'time_return', '2026-02-23')
assert.equal(editor.resolveApplicationPreviewEditorControl('time_return'), 'date')
assert.equal(editor.applicationPreviewEditor.value.draftValue, '2026-02-23')
assert.equal(editor.resolveApplicationPreviewEditorDateMin(message, 'time_return'), '2026-02-20')
})
test('application preview editor blocks invalid date ranges', async () => {
const preview = normalizeApplicationPreview({
fields: {
applicationType: '\u5dee\u65c5\u8d39\u7528\u7533\u8bf7',
time: '2026-02-20 \u81f3 2026-02-23',
location: '\u4e0a\u6d77',
reason: '\u670d\u52a1\u9879\u76ee\u90e8\u7f72',
days: '4\u5929',
transportMode: '\u706b\u8f66',
amount: ''
}
})
const message = {
id: 'application-preview-editor-invalid-date-message',
applicationPreview: preview,
text: ''
}
const toastMessages = []
const editor = useApplicationPreviewEditor({
persistSessionState: () => {},
toast: (messageText) => {
toastMessages.push(messageText)
}
})
editor.openApplicationPreviewEditor(message, 'time_return', '2026-02-23')
editor.applicationPreviewEditor.value.draftValue = '2026-02-19'
const returnCommitted = await editor.commitApplicationPreviewEditor(message)
assert.equal(returnCommitted, false)
assert.equal(message.applicationPreview.fields.time, '2026-02-20 \u81f3 2026-02-23')
assert.equal(message.applicationPreview.fields.days, '4\u5929')
assert.equal(toastMessages.at(-1), '\u51fa\u53d1\u65f6\u95f4\u4e0d\u80fd\u665a\u4e8e\u8fd4\u56de\u65f6\u95f4\uff0c\u8bf7\u91cd\u65b0\u9009\u62e9\u3002')
editor.openApplicationPreviewEditor(message, 'time', message.applicationPreview.fields.time)
editor.applicationPreviewEditor.value.draftValue = '2026-02-24'
const startCommitted = await editor.commitApplicationPreviewEditor(message)
assert.equal(startCommitted, false)
assert.equal(message.applicationPreview.fields.time, '2026-02-20 \u81f3 2026-02-23')
assert.equal(toastMessages.at(-1), '\u51fa\u53d1\u65f6\u95f4\u4e0d\u80fd\u665a\u4e8e\u8fd4\u56de\u65f6\u95f4\uff0c\u8bf7\u91cd\u65b0\u9009\u62e9\u3002')
})
test('application preview editor estimates after shorthand return date input', async () => {
const preview = normalizeApplicationPreview({
fields: {
applicationType: '\u5dee\u65c5\u8d39\u7528\u7533\u8bf7',
time: '2026-06-23',
location: '\u5317\u4eac',
reason: '\u652f\u6491\u5ba2\u6237\u73b0\u573a\u5b9e\u65bd',
days: '',
transportMode: '',
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-shorthand-return-date-message',
applicationPreview: preview,
text: ''
}
const requestedPayloads = []
const editor = useApplicationPreviewEditor({
persistSessionState: () => {},
toast: () => {},
currentUser: ref({ grade: 'P5', location: '\u6b66\u6c49' }),
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: 1350,
total_allowance_rate: 100,
allowance_amount: 300,
total_amount: 1650,
rule_name: '\u516c\u53f8\u5dee\u65c5\u8d39\u62a5\u9500\u89c4\u5219',
rule_version: 'v1.0.0'
}
}
})
editor.openApplicationPreviewEditor(message, 'time_return', '\u5f85\u8865\u5145')
editor.applicationPreviewEditor.value.draftValue = '6\u670825\u65e5'
const committed = await editor.commitApplicationPreviewEditor(message)
assert.equal(committed, true)
assert.deepEqual(requestedPayloads.at(-1), {
days: 3,
location: '\u5317\u4eac',
grade: 'P5',
transport_mode: null,
origin_location: '\u6b66\u6c49',
travel_date: '2026-06-23'
})
assert.equal(message.applicationPreview.fields.time, '2026-06-23 \u81f3 2026-06-25')
assert.equal(message.applicationPreview.fields.days, '3\u5929')
assert.equal(message.applicationPreview.fields.lodgingDailyCap, '450\u5143/\u5929')
assert.equal(message.applicationPreview.fields.subsidyDailyCap, '100\u5143/\u5929')
assert.equal(message.applicationPreview.fields.amount, '1,650\u5143\uff08\u4e0d\u542b\u4ea4\u901a\uff09')
assert.match(message.applicationPreview.fields.policyEstimate, /\u4ea4\u901a\u5f85\u8865\u5145/)
})