Files
X-Financial/web/tests/expense-application-fast-preview.test.mjs
caoxiaozhu e124e4bbcb feat: 报销审批流重构与管家计划全链路贯通
- 重构报销状态注册表、审批流路由与平台风险标记
- 完善管家意图规划器与模型计划构建器全链路
- 新增 OCR Worker 脚本、数据库会话管理与通知状态
- 优化文档中心、日志视图、预算中心与员工管理交互
- 增强工作台摘要、图标资源与全局主题样式
- 补充审批路由、状态注册、OCR 服务与管家规划器测试覆盖
2026-06-06 17:19:07 +08:00

1233 lines
63 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,
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 { 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 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 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 去上海支撑上海电力部署项目出差3天高铁预计金额2358元'
assert.equal(
shouldUseLocalApplicationPreview(prompt, {
sessionType: 'application',
attachmentCount: 0,
reviewAction: '',
systemGenerated: false
}),
true
)
assert.equal(
shouldUseLocalApplicationPreview('帮我查询申请状态', {
sessionType: 'application',
attachmentCount: 0,
reviewAction: '',
systemGenerated: false
}),
false
)
const preview = buildLocalApplicationPreview(prompt, {
name: '李文静',
departmentName: '财务部',
position: '财务分析师',
managerName: '王强',
grade: 'P5'
})
assert.equal(preview.fields.applicationType, '差旅费用申请')
assert.equal(preview.fields.time, '2026-05-20 至 2026-05-23')
assert.equal(preview.fields.location, '上海')
assert.equal(preview.fields.days, '3天')
assert.equal(preview.fields.transportMode, '火车')
assert.equal(preview.fields.amount, '2358元')
assert.equal(preview.fields.applicant, '李文静')
assert.equal(preview.fields.grade, 'P5')
assert.equal(preview.fields.department, '财务部')
assert.equal(preview.fields.position, '财务分析师')
assert.equal(preview.fields.managerName, '王强')
assert.equal(preview.readyToSubmit, true)
assert.doesNotMatch(buildLocalApplicationPreviewMessage(preview), /#application-submit/)
assert.match(buildApplicationPreviewFooterMessage(preview), /#application-submit/)
assert.match(buildLocalApplicationPreviewMessage(preview), /点击对应行即可直接编辑/)
})
test('travel application submit can continue with conversational planning recommendation', () => {
const preview = normalizeApplicationPreview({
fields: {
applicationType: '差旅费用申请',
time: '2026-02-20 至 2026-02-23',
location: '上海市',
reason: '支撑国网仿生产环境建设',
days: '4天',
transportMode: '火车'
}
})
const draftPayload = { claim_no: 'AP-202606030001-ABCDE123' }
const nudge = buildTravelPlanningNudgeMessage(preview, draftPayload)
const actions = buildTravelPlanningSuggestedActions(preview, draftPayload)
const recommendation = buildTravelPlanningRecommendation(preview, draftPayload)
assert.match(nudge, /上海市差旅申请已经提交/)
assert.match(nudge, /2026-02-20 至 2026-02-23/)
assert.deepEqual(actions.map((item) => item.action_type), [
TRAVEL_PLANNING_ACTION_GENERATE,
TRAVEL_PLANNING_ACTION_SKIP
])
assert.match(recommendation, /轻量行程规划/)
assert.match(recommendation, /优先看上午到中午抵达 上海市 的火车班次/)
assert.match(recommendation, /客户现场周边/)
assert.match(recommendation, /AP-202606030001-ABCDE123/)
})
test('application preview renders ordered editable rows and submit text uses edited values', () => {
const preview = buildLocalApplicationPreview('申请 2026-05-25 至 2026-05-28 去新疆伊犁出差服务美团业务部署火车预计费用1800元', {
name: '李文静',
departmentName: '财务部',
position: '财务分析师',
managerName: '王强',
grade: 'P5'
})
assert.equal(preview.fields.location, '新疆,伊犁')
assert.equal(preview.fields.reason, '服务美团业务部署')
const editedPreview = normalizeApplicationPreview({
...preview,
fields: {
...preview.fields,
reason: '客户现场项目支持',
amount: '1900元'
}
})
const rows = buildApplicationPreviewRows(editedPreview)
assert.deepEqual(
rows.map((row) => row.label),
['申请类型', '姓名', '职级', '部门', '岗位', '直属领导', '出发时间', '返回时间', '地点', '事由', '天数', '出行方式', '住宿上限/天', '补贴标准/天', '交通费用口径', '规则测算参考', '系统预估费用']
)
assert.match(buildApplicationPreviewSubmitText(editedPreview), /出发时间2026-05-25/)
assert.match(buildApplicationPreviewSubmitText(editedPreview), /返回时间2026-05-28/)
assert.doesNotMatch(buildApplicationPreviewSubmitText(editedPreview), /发生时间:/)
assert.equal(rows.find((row) => row.key === 'amount')?.value, '1900元')
assert.equal(rows.find((row) => row.key === 'amount')?.highlight, true)
assert.equal(rows.find((row) => row.key === 'amount')?.editable, false)
assert.equal(rows.find((row) => row.key === 'applicant')?.editable, false)
assert.equal(rows.find((row) => row.key === 'grade')?.editable, false)
assert.equal(rows.find((row) => row.key === 'department')?.editable, false)
assert.equal(rows.find((row) => row.key === 'position')?.editable, false)
assert.equal(rows.find((row) => row.key === 'managerName')?.editable, false)
assert.equal(rows.find((row) => row.key === 'lodgingDailyCap')?.editable, false)
assert.match(buildApplicationPreviewSubmitText(editedPreview), /姓名:李文静/)
assert.match(buildApplicationPreviewSubmitText(editedPreview), /部门:财务部/)
assert.match(buildApplicationPreviewSubmitText(editedPreview), /岗位:财务分析师/)
assert.match(buildApplicationPreviewSubmitText(editedPreview), /直属领导:王强/)
assert.match(buildApplicationPreviewSubmitText(editedPreview), /事由:客户现场项目支持/)
assert.match(buildApplicationPreviewSubmitText(editedPreview), /系统预估费用1900元/)
})
test('application estimate builds deterministic mock transport amount and total', () => {
const trainEstimate = buildMockApplicationTransportEstimate({ transportMode: '高铁', location: '上海' })
const datedTrainEstimate = buildMockApplicationTransportEstimate({
transportMode: '高铁',
location: '上海',
time: '2026-05-25 至 2026-05-28'
})
const flightEstimate = buildMockApplicationTransportEstimate({ transportMode: '机票', location: '新疆,伊犁' })
const shipEstimate = buildMockApplicationTransportEstimate({ transportMode: '船票', location: '厦门' })
const totalEstimate = buildSystemApplicationEstimate({
transportMode: '火车',
location: '上海',
lodgingAmount: 1800,
allowanceAmount: 360
})
const datedTotalEstimate = buildSystemApplicationEstimate({
transportMode: '火车',
location: '上海',
time: '2026-05-25 至 2026-05-28',
lodgingAmount: 1800,
allowanceAmount: 360
})
assert.equal(trainEstimate.amountDisplay, '1,040')
assert.equal(datedTrainEstimate.queryDate, '2026-05-25')
assert.equal(datedTrainEstimate.amountDisplay, '1,100')
assert.equal(datedTrainEstimate.source, 'mock_ticket_price_query_v1')
assert.equal(datedTrainEstimate.basisText, '预估交通费用 1,100元')
assert.ok(datedTrainEstimate.simulatedLatencyMs >= 360)
assert.ok(datedTrainEstimate.simulatedLatencyMs <= 779)
assert.equal(resolveMockApplicationTransportWaitMs(datedTrainEstimate), 320)
assert.equal(flightEstimate.amountDisplay, '3,600')
assert.equal(shipEstimate.amountDisplay, '1,040')
assert.equal(totalEstimate.transportAmountDisplay, '1,040')
assert.equal(totalEstimate.totalAmountDisplay, '3,200')
assert.equal(datedTotalEstimate.transportAmountDisplay, '1,100')
assert.equal(datedTotalEstimate.totalAmountDisplay, '3,260')
})
test('application preview uses selected date range and business-specific time label', () => {
const preview = applyApplicationBusinessTimeContext(
buildLocalApplicationPreview(
'去上海出差4天支撑国网仿生产环境部署飞机',
{
name: '曹笑竹',
departmentName: '技术部',
position: '财务智能化产品经理',
managerName: '向万红',
grade: 'P5'
},
{ today: '2026-06-02' }
),
{
mode: 'range',
start_date: '2026-02-20',
end_date: '2026-02-23',
business_time: '2026-02-20 至 2026-02-23'
}
)
const rows = buildApplicationPreviewRows(preview)
const submitText = buildApplicationPreviewSubmitText(preview)
assert.equal(resolveApplicationTimeLabel(preview.fields.applicationType), '出发时间')
assert.equal(preview.fields.time, '2026-02-20 至 2026-02-23')
assert.equal(preview.fields.days, '4天')
assert.equal(preview.fields.reason, '支撑国网仿生产环境部署')
assert.equal(rows.find((row) => row.key === 'time')?.label, '出发时间')
assert.equal(rows.find((row) => row.key === 'time')?.value, '2026-02-20')
assert.equal(rows.find((row) => row.key === 'time_return')?.label, '返回时间')
assert.equal(rows.find((row) => row.key === 'time_return')?.value, '2026-02-23')
assert.match(submitText, /出发时间2026-02-20/)
assert.match(submitText, /返回时间2026-02-23/)
assert.match(submitText, /事由:支撑国网仿生产环境部署/)
assert.doesNotMatch(submitText, /发生时间:/)
})
test('application preview keeps labeled reason in structured travel form', () => {
const preview = buildLocalApplicationPreview([
'发生时间2026-02-20 至 2026-02-23',
'地点:上海',
'事由:支撑国网仿生产环境建设',
'天数4天'
].join('\n'), {
name: '曹笑竹',
grade: 'P5'
})
const rows = buildApplicationPreviewRows(preview)
assert.equal(preview.fields.applicationType, '差旅费用申请')
assert.equal(preview.fields.time, '2026-02-20 至 2026-02-23')
assert.equal(preview.fields.location, '上海')
assert.equal(preview.fields.reason, '支撑国网仿生产环境建设')
assert.equal(preview.fields.days, '4天')
assert.equal(rows.find((row) => row.key === 'reason')?.value, '支撑国网仿生产环境建设')
assert.equal(rows.find((row) => row.key === 'reason')?.missing, false)
assert.equal(rows.find((row) => row.key === 'time')?.label, '出发时间')
assert.equal(rows.find((row) => row.key === 'time_return')?.label, '返回时间')
})
test('application preview cleans empty time labels and keeps only business reason', () => {
const preview = buildLocalApplicationPreview('发生时间去九江出差3天服务美团业务部署预计费用1800元火车', {
name: '李文静',
grade: 'P5'
}, {
today: '2026-05-29'
})
assert.equal(preview.fields.time, '2026-05-29 至 2026-05-31')
assert.equal(preview.fields.location, '九江')
assert.equal(preview.fields.days, '3天')
assert.equal(preview.fields.reason, '服务美团业务部署')
assert.equal(preview.fields.transportMode, '火车')
assert.doesNotMatch(preview.fields.reason, /发生时间|去九江|出差3天/)
})
test('application preview can be refined by ontology model extraction', () => {
const rawText = '发生时间去九江出差3天服务美团业务部署预计费用1800元火车'
const localPreview = buildLocalApplicationPreview(rawText, { name: '李文静', grade: 'P5' }, { today: '2026-05-29' })
const refinedPreview = buildModelRefinedApplicationPreview(
localPreview,
{
parse_strategy: 'llm_primary',
entities: [
{ type: 'expense_type', value: '差旅费', normalized_value: 'travel' },
{ type: 'location', value: '九江', normalized_value: '九江' },
{ type: 'reason', value: '服务美团业务部署', normalized_value: '服务美团业务部署' },
{ type: 'transport_mode', value: '火车', normalized_value: '火车' },
{ type: 'amount', value: '1800元', normalized_value: '1800' }
],
time_range: {},
missing_slots: []
},
rawText,
{ name: '李文静', grade: 'P5' }
)
assert.equal(refinedPreview.modelRefined, true)
assert.equal(refinedPreview.parseStrategy, 'llm_primary')
assert.equal(refinedPreview.modelReviewStatus, 'completed')
assert.equal(refinedPreview.fields.applicationType, '差旅费用申请')
assert.equal(refinedPreview.fields.time, '2026-05-29 至 2026-05-31')
assert.equal(refinedPreview.fields.reason, '服务美团业务部署')
assert.equal(refinedPreview.fields.transportMode, '火车')
})
test('application preview ignores model-only transport mode guesses', () => {
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.ok(refinedPreview.missingFields.includes('\u51fa\u884c\u65b9\u5f0f'))
assert.equal(refinedPreview.readyToSubmit, false)
})
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.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(createViewScript, /message-bubble-application-preview/)
assert.match(createViewScript, /buildApplicationPreviewFooterMessage/)
assert.match(createViewScript, /function buildApplicationPreviewFooterText\(message\)/)
assert.match(createViewScript, /buildApplicationPreviewSubmitText/)
assert.match(createViewScript, /user_input_text: applicationSubmitText/)
assert.match(conversationModelScript, /applicationPreview: null/)
assert.match(conversationModelScript, /applicationPreview: message\.applicationPreview \|\| null/)
assert.match(conversationModelScript, /\|\| message\.applicationPreview/)
assert.match(createViewScript, /hasMeaningfulSessionMessages\(messages\.value\)/)
assert.match(messageItemTemplate, /class="application-preview-table"/)
assert.match(messageItemTemplate, /class="application-preview-footer application-preview-footer-missing"/)
assert.match(messageItemTemplate, /application-preview-missing-chip/)
assert.match(messageItemTemplate, /当前还需要补充:/)
assert.match(messageItemTemplate, /补齐后我再帮您提交申请。/)
assert.match(messageItemTemplate, /class="application-preview-footer message-answer-content message-answer-markdown"/)
assert.match(messageItemTemplate, /v-html="ui\.renderMarkdown\(ui\.buildApplicationPreviewFooterText\(message\)\)"/)
assert.doesNotMatch(messageItemTemplate, /class="application-date-editor-layer"/)
assert.doesNotMatch(messageItemTemplate, /ui\.commitApplicationPreviewDateEditor\(message\)/)
assert.match(messageItemTemplate, /application-preview-date-chip/)
assert.match(messageItemTemplate, /申请单据已生成/)
assert.match(messageItemTemplate, /ui\.shouldShowDraftSavedCard\(message\)/)
assert.match(messageItemTemplate, /报销草稿已生成/)
assert.match(messageItemTemplate, /报销草稿待保存/)
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(createViewScript, /function canOpenDraftDetail\(message\)/)
assert.match(createViewScript, /canOpenDraftDetail,/)
assert.match(createViewScript, /保存后生成/)
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(createViewScript, /function shouldShowAssistantMessageActions/)
assert.match(createViewScript, /function buildMessageOperationFeedbackContext/)
assert.match(createViewScript, /function isMessageFeedbackSelected/)
assert.match(createViewScript, /function submitOperationFeedbackForMessage/)
assert.match(createViewScript, /const stewardSubmitContinuation = message\?\.stewardContinuation \|\| null/)
assert.match(createViewScript, /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 asks before rendering 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, /applicationPreview:\s*normalized/)
assert.match(submitComposerScript, /请先告诉我你打算怎么出行:\*\*火车、飞机或轮船\*\*/)
assert.doesNotMatch(submitComposerScript, /缺少“出行方式”[\s\S]{0,500}更新下方核对表/)
assert.match(createViewScript, /payload\.applicationPreview/)
assert.match(createViewScript, /function continueStewardApplicationFieldCompletion/)
assert.match(createViewScript, /submitComposerInternal\(\{[\s\S]*stewardContinuation: continuation/)
assert.match(createViewScript, /skipUserMessage:\s*true/)
assert.match(createViewScript, /targetMessage\.applicationPreview = normalizeApplicationPreview\(sourcePreview\)/)
assert.match(createViewScript, /openApplicationPreviewEditor\(targetMessage, fieldKey/)
assert.match(createViewScript, /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(createViewScript, /function buildStewardRuntimeState/)
assert.match(createViewScript, /function buildStewardRuntimeFastPathDecision/)
assert.match(createViewScript, /function shouldUseStewardRuntimeLlmDecision/)
assert.match(createViewScript, /function findPendingSlotSuggestedActionContextByInput/)
assert.match(createViewScript, /function shouldPlanNewStewardTasksLocally/)
assert.match(createViewScript, /function resolveStewardRuntimeTransportAlias/)
assert.match(createViewScript, /const actionTransportAlias = resolveStewardRuntimeTransportAlias/)
assert.match(createViewScript, /actionTransportAlias === transportAlias/)
assert.match(createViewScript, /next_action:\s*'continue_next_task'/)
assert.match(createViewScript, /next_action:\s*'submit_current_application'/)
assert.match(createViewScript, /next_action:\s*'fill_current_slot'/)
assert.match(createViewScript, /next_action:\s*'plan_new_tasks'/)
assert.match(createViewScript, /suppressUserEcho:\s*userMessageAlreadyAdded/)
assert.match(createViewScript, /if \(!action\?\.suppressUserEcho\) \{[\s\S]*messages\.value\.push\(createMessage\('user', userText\)\)/)
assert.match(createViewScript, /skipApplicationModelReview:\s*true/)
assert.match(createViewScript, /skipApplicationModelReview:\s*targetSessionType === SESSION_TYPE_APPLICATION/)
assert.match(createViewScript, /skipStewardSlotDecision:\s*targetSessionType === SESSION_TYPE_APPLICATION/)
assert.match(submitComposerScript, /skipModelReview:\s*Boolean\(stewardDelegated && options\.skipApplicationModelReview\)/)
assert.match(submitComposerScript, /if \(options\.skipModelReview\) \{[\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(createViewScript, /async function handleStewardRuntimeDecision/)
assert.match(createViewScript, /const runtimeState = buildStewardRuntimeState\(\)/)
assert.match(createViewScript, /if \(!hasActiveStewardRuntimeDecisionContext\(runtimeState\)\) \{[\s\S]*return false/)
assert.match(createViewScript, /function pushStewardRuntimeUserMessage\(userText = ''\)/)
assert.match(createViewScript, /const userMessageAlreadyAdded = options\.skipUserMessage[\s\S]*pushStewardRuntimeUserMessage\(rawText\)/)
assert.match(createViewScript, /const fastDecision = buildStewardRuntimeFastPathDecision\(rawText, runtimeState\)[\s\S]*submitStewardPlan\(\{[\s\S]*skipUserMessage: userMessageAlreadyAdded \|\| options\.skipUserMessage/)
assert.match(createViewScript, /executeStewardRuntimeDecision\(fastDecision, rawText, \{ userMessageAlreadyAdded \}\)[\s\S]*if \(!shouldUseStewardRuntimeLlmDecision\(rawText, runtimeState\)\)/)
assert.match(createViewScript, /if \(!shouldUseStewardRuntimeLlmDecision\(rawText, runtimeState\)\)[\s\S]*fetchStewardRuntimeDecision/)
assert.match(createViewScript, /executeStewardRuntimeDecision\(decision, rawText, \{ userMessageAlreadyAdded \}\)/)
assert.match(createViewScript, /skipUserMessage: userMessageAlreadyAdded \|\| options\.skipUserMessage/)
assert.match(createViewScript, /fetchStewardRuntimeDecision\(\{[\s\S]*runtime_state: runtimeState/)
assert.match(createViewScript, /if \(await handleStewardRuntimeDecision\(options\)\) \{[\s\S]*return null/)
assert.match(createViewScript, /function isApplicationSubmitConfirmationText/)
assert.match(createViewScript, /APPLICATION_SUBMIT_CONFIRM_TEXT_PATTERN[\s\S]*确认提交[\s\S]*提交审批/)
assert.match(createViewScript, /function findPendingApplicationSubmitMessage/)
assert.match(createViewScript, /normalizedPreview\.readyToSubmit/)
assert.match(createViewScript, /async function handleApplicationSubmitConfirmationText/)
assert.match(createViewScript, /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(createViewScript, /message\.applicationSubmitConfirmed = true/)
assert.match(createViewScript, /message\.applicationSubmitConfirmed[\s\S]*continue/)
})
test('steward streaming uses chunked typewriter to reduce perceived latency', () => {
assert.match(stewardPlanFlowScript, /STEWARD_TYPEWRITER_CHUNK_SIZE = 4/)
assert.match(stewardPlanFlowScript, /STEWARD_THINKING_TYPEWRITER_CHUNK_SIZE = 5/)
assert.match(stewardPlanFlowScript, /index = Math\.min\(total, index \+ STEWARD_TYPEWRITER_CHUNK_SIZE\)/)
assert.match(stewardPlanFlowScript, /index = Math\.min\(chars\.length, index \+ STEWARD_THINKING_TYPEWRITER_CHUNK_SIZE\)/)
assert.match(submitComposerScript, /STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE = 4/)
assert.match(submitComposerScript, /STEWARD_DELEGATED_THINKING_CHUNK_SIZE = 5/)
assert.match(submitComposerScript, /index = Math\.min\(chars\.length, index \+ STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE\)/)
assert.match(submitComposerScript, /index = Math\.min\(chars\.length, index \+ STEWARD_DELEGATED_THINKING_CHUNK_SIZE\)/)
assert.match(createViewScript, /STEWARD_FOLLOWUP_TYPEWRITER_CHUNK_SIZE = 4/)
assert.match(createViewScript, /STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE = 5/)
assert.match(createViewScript, /index = Math\.min\(chars\.length, index \+ STEWARD_FOLLOWUP_TYPEWRITER_CHUNK_SIZE\)/)
assert.match(createViewScript, /index = Math\.min\(chars\.length, index \+ STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE\)/)
})
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.match(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(createViewScript, /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(createViewScript, /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: 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('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(createViewScript, /actionType === 'open_application_detail'/)
})
test('application preview merges rule center travel estimate into highlighted rows', () => {
const preview = buildLocalApplicationPreview('申请 2026-05-25 至 2026-05-28 去上海出差3天服务项目部署火车预计费用1800元', {
name: '李文静',
grade: 'P5'
})
const request = buildApplicationPolicyEstimateRequest(preview, { grade: 'P5' })
assert.equal(request.canCalculate, true)
assert.deepEqual(request.payload, { days: 3, location: '上海', grade: 'P5' })
const estimatedPreview = applyApplicationPolicyEstimateResult(preview, {
days: 3,
location: '上海',
matched_city: '上海',
grade: 'P5',
hotel_rate: 600,
hotel_amount: 1800,
total_allowance_rate: 120,
allowance_amount: 360,
total_amount: 2160,
rule_name: '公司差旅费报销规则',
rule_version: '2026版'
}, { grade: 'P5' })
assert.equal(estimatedPreview.fields.lodgingDailyCap, '600元/天')
assert.equal(estimatedPreview.fields.subsidyDailyCap, '120元/天')
assert.equal(estimatedPreview.fields.transportPolicy, '预估交通费用 1,100元')
assert.doesNotMatch(estimatedPreview.fields.transportPolicy, /参考票价|查询耗时|2026-05-25|真实票据/)
assert.match(estimatedPreview.fields.policyEstimate, /交通 1,100元/)
assert.match(estimatedPreview.fields.policyEstimate, /3,260元/)
assert.equal(estimatedPreview.fields.transportEstimatedAmount, '1,100元')
assert.equal(estimatedPreview.fields.transportEstimateDate, '2026-05-25')
assert.match(estimatedPreview.fields.transportQueryLatencyMs, /^\d+ms$/)
assert.equal(estimatedPreview.fields.amount, '3,260元')
assert.equal(buildApplicationPreviewRows(estimatedPreview).find((row) => row.key === 'policyEstimate')?.highlight, true)
})
test('application preview editor refreshes transport estimate after mode change', async () => {
const preview = applyApplicationPolicyEstimateResult(
buildLocalApplicationPreview('申请 2026-05-25 至 2026-05-27 去上海出差3天服务项目部署', {
name: '李文静',
grade: 'P5'
}),
{
days: 3,
location: '上海',
matched_city: '上海',
grade: 'P5',
hotel_rate: 600,
hotel_amount: 1800,
total_allowance_rate: 120,
allowance_amount: 360,
total_amount: 2160
},
{ grade: 'P5' }
)
const message = {
id: 'application-preview-editor-message',
applicationPreview: preview,
text: ''
}
let persistCount = 0
const toastMessages = []
const editor = useApplicationPreviewEditor({
persistSessionState: () => {
persistCount += 1
},
toast: (messageText) => {
toastMessages.push(messageText)
}
})
editor.openApplicationPreviewEditor(message, 'transportMode', '待补充')
editor.applicationPreviewEditor.value.draftValue = '飞机'
const committed = await editor.commitApplicationPreviewEditor(message)
assert.equal(committed, true)
assert.equal(message.applicationPreview.fields.transportMode, '飞机')
assert.equal(message.applicationPreview.fields.transportEstimatedAmount, '2,330元')
assert.equal(message.applicationPreview.fields.amount, '4,490元')
assert.equal(message.applicationPreview.fields.transportPolicy, '预估交通费用 2,330元')
assert.doesNotMatch(message.applicationPreview.fields.transportPolicy, /参考票价|查询耗时|2026-05-25|真实票据/)
assert.doesNotMatch(message.applicationPreview.fields.transportPolicy, /模拟/)
assert.ok(persistCount >= 2)
assert.equal(toastMessages.at(-1), '已更新出行方式和费用测算。')
})
test('application preview editor recalculates days and subsidy after date range change', async () => {
const preview = normalizeApplicationPreview({
fields: {
applicationType: '\u5dee\u65c5\u8d39\u7528\u7533\u8bf7',
time: '2026-05-25',
location: '\u4e0a\u6d77',
reason: '\u670d\u52a1\u9879\u76ee\u90e8\u7f72',
days: '1\u5929',
transportMode: '\u706b\u8f66',
amount: '',
grade: 'P5',
applicant: '\u674e\u6587\u9759',
department: '\u6280\u672f\u90e8',
position: '\u8d22\u52a1\u667a\u80fd\u5316\u4ea7\u54c1\u7ecf\u7406',
managerName: '\u5411\u4e07\u7ea2'
}
})
const message = {
id: 'application-preview-editor-date-message',
applicationPreview: preview,
text: ''
}
const requestedPayloads = []
const editor = useApplicationPreviewEditor({
persistSessionState: () => {},
toast: () => {},
currentUser: ref({ grade: 'P5' }),
calculateTravelReimbursement: async (payload) => {
requestedPayloads.push(payload)
return {
days: payload.days,
location: payload.location,
matched_city: payload.location,
grade: payload.grade,
hotel_rate: 450,
hotel_amount: 1800,
total_allowance_rate: 100,
allowance_amount: 400,
total_amount: 2200,
rule_name: '\u516c\u53f8\u5dee\u65c5\u8d39\u62a5\u9500\u89c4\u5219',
rule_version: 'v1.0.0'
}
}
})
editor.openApplicationPreviewEditor(message, 'time', message.applicationPreview.fields.time)
editor.setApplicationPreviewDateMode('range')
editor.applicationPreviewEditor.value.rangeStartDate = '2026-02-20'
editor.applicationPreviewEditor.value.rangeEndDate = '2026-02-23'
const committed = await editor.commitApplicationPreviewDateEditor(message)
assert.equal(committed, true)
assert.deepEqual(requestedPayloads.at(-1), { days: 4, location: '\u4e0a\u6d77', grade: 'P5' })
assert.equal(message.applicationPreview.fields.time, '2026-02-20 \u81f3 2026-02-23')
assert.equal(message.applicationPreview.fields.days, '4\u5929')
assert.equal(message.applicationPreview.fields.lodgingDailyCap, '450\u5143/\u5929')
assert.equal(message.applicationPreview.fields.subsidyDailyCap, '100\u5143/\u5929')
assert.match(message.applicationPreview.fields.policyEstimate, /\u8865\u8d34 400\u5143/)
})