Files
X-Financial/web/tests/travel-reimbursement-review-drawer-switch.test.mjs
caoxiaozhu ded8b39ccb feat(web): 申请单预览编辑器增强与报销流程细节适配
- useApplicationPreviewEditor 扩展字段编辑与校验,useTravelReimbursementApplicationPreviewDateEditor 微调日期处理
- travelReimbursementExpenseQueryModel/reimbursements 服务/expenseApplicationPreview 适配工号/邮箱字段与关联动作
- useWorkbenchAiApplicationPreviewFlow/usePersonalWorkbenchAiMode 接入关联门控后的预览流转
- TravelReimbursementCreateView 调整入口,TravelReimbursementMessageItem 适配
- 新增 expense-application-fast-preview 测试,更新 attachment-association-confirmation、review-drawer-switch 测试
2026-06-22 15:56:06 +08:00

686 lines
39 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 {
buildReviewFormContextFromPayload,
buildLocallySyncedReviewPayload,
buildReviewNextStepRichCopy,
buildReviewPlainFollowupCopy,
isTravelReviewPayload,
resolveReviewFooterActions
} from '../src/views/scripts/travelReimbursementReviewModel.js'
import { useTravelReimbursementAttachments } from '../src/views/scripts/useTravelReimbursementAttachments.js'
import { renderMarkdown } from '../src/utils/markdown.js'
const createViewTemplate = readFileSync(
fileURLToPath(new URL('../src/views/TravelReimbursementCreateView.vue', import.meta.url)),
'utf8'
)
const createViewScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
'utf8'
)
const createViewScriptSurface = [
'../src/views/scripts/TravelReimbursementCreateView.js',
'../src/views/scripts/useTravelReimbursementComposerTools.js',
'../src/views/scripts/useTravelReimbursementCreateViewControls.js',
'../src/views/scripts/useTravelReimbursementCreateViewDrawerControls.js',
'../src/views/scripts/useTravelReimbursementCreateViewLifecycle.js',
'../src/views/scripts/useTravelReimbursementCreateViewMessageHandlers.js',
'../src/views/scripts/useTravelReimbursementCreateViewState.js',
'../src/views/scripts/useTravelReimbursementCreateViewTravelCalculator.js',
'../src/views/scripts/useTravelReimbursementCreateViewUi.js',
'../src/views/scripts/useTravelReimbursementMessageActions.js',
'../src/views/scripts/useTravelReimbursementReviewActions.js'
].map((path) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')).join('\n')
const reviewPanelModelScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelReimbursementReviewPanelModel.js', import.meta.url)),
'utf8'
)
const messageItemTemplate = readFileSync(
fileURLToPath(new URL('../src/components/travel/TravelReimbursementMessageItem.vue', import.meta.url)),
'utf8'
)
const insightPanelTemplate = readFileSync(
fileURLToPath(new URL('../src/components/travel/TravelReimbursementInsightPanel.vue', import.meta.url)),
'utf8'
)
const createViewTemplateSurface = [
createViewTemplate,
messageItemTemplate,
insightPanelTemplate
].join('\n')
const reimbursementService = readFileSync(
fileURLToPath(new URL('../src/services/reimbursements.js', import.meta.url)),
'utf8'
)
const reimbursementFlowScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementFlow.js', import.meta.url)),
'utf8'
)
const flowTimingScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelReimbursementFlowTiming.js', import.meta.url)),
'utf8'
)
const reviewActionsScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementReviewActions.js', import.meta.url)),
'utf8'
)
const reviewDrawerScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementReviewDrawer.js', import.meta.url)),
'utf8'
)
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 attachmentsScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementAttachments.js', import.meta.url)),
'utf8'
)
const attachmentSyncScript = readFileSync(
fileURLToPath(new URL('../src/utils/expenseClaimAttachmentSync.js', import.meta.url)),
'utf8'
)
const sessionStateScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSessionState.js', import.meta.url)),
'utf8'
)
const createViewBaseStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/views/travel-reimbursement-create-view.css', import.meta.url)),
'utf8'
)
const createViewPart2Styles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/views/travel-reimbursement-create-view-part2.css', import.meta.url)),
'utf8'
)
const createViewPart3Styles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/views/travel-reimbursement-create-view-part3.css', import.meta.url)),
'utf8'
)
const createViewPart4Styles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/views/travel-reimbursement-create-view-part4.css', import.meta.url)),
'utf8'
)
const insightPanelStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/components/travel-reimbursement-insight-panel.css', import.meta.url)),
'utf8'
)
test('review drawer tools expose the default review tab before conditional document and risk tabs', () => {
assert.match(createViewTemplateSurface, /v-if="ui\.activeReviewPayload && ui\.reviewOverviewDrawerAvailable"[\s\S]*title="报销识别核对"[\s\S]*@click="ui\.switchToReviewOverviewDrawer"/)
assert.match(createViewTemplateSurface, /v-if="ui\.activeReviewPayload && ui\.reviewDocumentDrawerAvailable"[\s\S]*title="单据识别"/)
assert.match(createViewTemplateSurface, /v-if="ui\.activeReviewPayload && ui\.reviewRiskDrawerAvailable"[\s\S]*title="显示风险"/)
assert.match(createViewTemplateSurface, /title="调用流程"/)
assert.ok(
createViewTemplateSurface.indexOf('title="报销识别核对"') < createViewTemplateSurface.indexOf('title="单据识别"'),
'default review button should be placed before the document recognition button'
)
})
test('review drawer tool buttons switch modes instead of toggling the active mode closed', () => {
assert.match(createViewScriptSurface, /const isReviewOverviewDrawer = computed\(\(\) => reviewDrawerMode\.value === REVIEW_DRAWER_MODE_REVIEW\)/)
assert.match(createViewScriptSurface, /function switchReviewDrawerMode\(mode\) \{[\s\S]*if \(reviewDrawerMode\.value === mode\) \{[\s\S]*return[\s\S]*\}/)
assert.match(createViewScriptSurface, /function switchToReviewOverviewDrawer\(\) \{[\s\S]*switchReviewDrawerMode\(REVIEW_DRAWER_MODE_REVIEW\)/)
assert.match(createViewScriptSurface, /function toggleReviewDocumentDrawer\(\) \{[\s\S]*switchReviewDrawerMode\(REVIEW_DRAWER_MODE_DOCUMENTS\)/)
assert.match(createViewScriptSurface, /function toggleReviewRiskDrawer\(\) \{[\s\S]*switchReviewDrawerMode\(REVIEW_DRAWER_MODE_RISK\)/)
assert.match(createViewScriptSurface, /function toggleReviewFlowDrawer\(\) \{[\s\S]*switchReviewDrawerMode\(REVIEW_DRAWER_MODE_FLOW\)/)
assert.doesNotMatch(createViewScriptSurface, /REVIEW_DRAWER_MODE_DOCUMENTS\s*\?\s*REVIEW_DRAWER_MODE_REVIEW/)
assert.doesNotMatch(createViewScriptSurface, /REVIEW_DRAWER_MODE_RISK\s*\?\s*REVIEW_DRAWER_MODE_REVIEW/)
assert.doesNotMatch(createViewScriptSurface, /REVIEW_DRAWER_MODE_FLOW\s*\?\s*REVIEW_DRAWER_MODE_REVIEW/)
})
test('document review drawer fills sidebar height and preview dialog is centered', () => {
assert.match(createViewTemplateSurface, /class="insight-body"[\s\S]*:class="\{ 'document-review-body': ui\.isReviewDocumentDrawer \}"/)
assert.match(createViewBaseStyles, /\.insight-panel-shell\s*\{[\s\S]*display:\s*flex;[\s\S]*min-height:\s*0;/)
assert.match(createViewPart2Styles, /\.insight-body\.document-review-body\s*\{[\s\S]*display:\s*flex;[\s\S]*overflow:\s*hidden;/)
assert.match(createViewPart2Styles, /\.review-ticket-drawer\s*\{[\s\S]*grid-template-rows:\s*auto minmax\(0,\s*1fr\);[\s\S]*height:\s*100%;[\s\S]*overflow:\s*hidden;/)
assert.match(createViewPart2Styles, /\.review-document-stage\s*\{[\s\S]*grid-template-rows:\s*auto auto minmax\(0,\s*1fr\);/)
assert.match(createViewPart2Styles, /\.review-document-scroll\s*\{[\s\S]*max-height:\s*none;[\s\S]*min-height:\s*0;/)
assert.match(createViewPart2Styles, /\.review-document-preview-card\.image\s*\{[\s\S]*place-items:\s*center;[\s\S]*min-height:\s*220px;/)
assert.match(createViewPart2Styles, /\.review-document-preview-card\.image img\s*\{[\s\S]*height:\s*auto;[\s\S]*object-fit:\s*contain;/)
assert.doesNotMatch(createViewPart2Styles, /\.review-document-preview-card\.image img\s*\{[\s\S]*object-fit:\s*cover;/)
assert.match(createViewPart4Styles, /\.review-overlay\s*\{[\s\S]*align-items:\s*center;[\s\S]*justify-content:\s*center;/)
assert.match(createViewPart4Styles, /\.review-preview-modal\s*\{[\s\S]*margin:\s*auto;[\s\S]*flex:\s*none;/)
})
test('assistant conversation keeps composer visible when generated cards grow tall', () => {
assert.match(createViewBaseStyles, /\.assistant-layout\s*\{[\s\S]*height:\s*100%;[\s\S]*max-height:\s*100%;[\s\S]*overflow:\s*hidden;/)
assert.match(
createViewBaseStyles,
/\.dialog-panel\s*\{[\s\S]*display:\s*flex;[\s\S]*flex-direction:\s*column;[\s\S]*height:\s*100%;[\s\S]*max-height:\s*100%;[\s\S]*min-height:\s*0;[\s\S]*overflow:\s*hidden;/
)
assert.match(createViewBaseStyles, /\.dialog-toolbar\s*\{[\s\S]*flex:\s*0 0 auto;/)
assert.match(
createViewBaseStyles,
/\.message-list\s*\{[\s\S]*flex:\s*1 1 0;[\s\S]*min-height:\s*0;[\s\S]*max-height:\s*100%;[\s\S]*overflow-y:\s*auto;[\s\S]*overscroll-behavior:\s*contain;/
)
assert.match(createViewBaseStyles, /\.composer\s*\{[\s\S]*position:\s*sticky;[\s\S]*bottom:\s*0;[\s\S]*flex:\s*0 0 auto;[\s\S]*flex-shrink:\s*0;/)
assert.match(createViewPart4Styles, /@media \(max-width:\s*1440px\)[\s\S]*\.dialog-panel\s*\{[\s\S]*flex:\s*1 1 0;[\s\S]*height:\s*auto;[\s\S]*max-height:\s*100%;/)
})
test('document review OCR result card header keeps copy and navigation separated', () => {
assert.match(insightPanelTemplate, /class="review-side-head-copy"[\s\S]*票据识别结果卡片[\s\S]*逐张查看 OCR 结果/)
assert.match(insightPanelStyles, /\.review-document-switch-head\s*\{[\s\S]*display:\s*grid;[\s\S]*grid-template-columns:\s*minmax\(0,\s*1fr\) auto;/)
assert.match(insightPanelStyles, /\.review-side-head-copy\s*\{[\s\S]*min-width:\s*0;[\s\S]*display:\s*grid;/)
assert.match(insightPanelStyles, /\.review-side-head-copy p\s*\{[\s\S]*overflow-wrap:\s*anywhere;/)
assert.match(insightPanelStyles, /\.review-document-nav\s*\{[\s\S]*flex:\s*0 0 auto;[\s\S]*flex-wrap:\s*nowrap;[\s\S]*white-space:\s*nowrap;/)
})
test('document preview avoids restored stale object urls', () => {
assert.match(createViewTemplateSurface, /v-if="documentPreviewDialog\.kind === 'image'"[\s\S]*:key="documentPreviewDialog\.renderKey"/)
assert.match(createViewTemplateSurface, /v-else-if="documentPreviewDialog\.kind === 'pdf'"[\s\S]*:key="documentPreviewDialog\.renderKey"/)
assert.match(reviewDrawerScript, /renderKey:\s*''/)
assert.match(reviewDrawerScript, /renderKey:\s*\[[\s\S]*Date\.now\(\)[\s\S]*\]\.join\('__'\)/)
assert.match(attachmentsScript, /isTemporaryPreviewUrl/)
assert.match(attachmentsScript, /existingPreview\?\.url && !isTemporaryPreviewUrl\(existingPreview\.url\)/)
assert.match(sessionStateScript, /filterPersistableFilePreviews\(state\.reviewFilePreviews\)/)
assert.doesNotMatch(sessionStateScript, /filterPersistableFilePreviews\(nextState\.reviewFilePreviews\)/)
})
test('local transport review no longer uses the travel hotel template', () => {
const reviewPayload = {
slot_cards: [
{
key: 'expense_type',
label: '报销类型',
value: '交通费',
normalized_value: 'transport',
status: 'identified'
}
],
document_cards: [
{
document_type: 'taxi_receipt',
suggested_expense_type: 'transport',
scene_label: '交通费'
}
]
}
assert.equal(isTravelReviewPayload(reviewPayload, { expense_type: '交通费' }), false)
assert.match(reviewPanelModelScript, /shouldShowReviewFactCard\(reviewPayload, 'customer_name'/)
assert.doesNotMatch(
reviewPanelModelScript,
/key:\s*'customer_name'[\s\S]{0,220}placeholder:\s*'请输入客户名称'[\s\S]{0,80}\},\s*\{[\s\S]{0,80}key:\s*'attachments'/
)
assert.match(createViewTemplateSurface, /placeholder="例如:出租车\/网约车票据 \/ 火车\/高铁票"/)
assert.doesNotMatch(createViewTemplateSurface, /票据场景[\s\S]{0,260}例如:业务招待费 \/ 差旅费/)
})
test('local save of changed reimbursement category updates edit fields too', () => {
const nextPayload = buildLocallySyncedReviewPayload(
{
can_proceed: false,
edit_fields: [
{ key: 'expense_type', label: '报销分类', value: '交通费' },
{ key: 'reason', label: '事由', value: '打车去客户现场' }
],
slot_cards: [
{
key: 'expense_type',
label: '报销类型',
value: '交通费',
normalized_value: 'transport',
required: true,
status: 'identified'
}
],
confirmation_actions: []
},
{
expense_type: '办公用品费',
reason_value: '右侧核对后改为办公用品费'
}
)
const expenseTypeField = nextPayload.edit_fields.find((item) => item.key === 'expense_type')
assert.equal(expenseTypeField.value, '办公用品费')
assert.equal(nextPayload.slot_cards[0].value, '办公用品费')
})
test('next step action uses rich text guidance and confirm dialog instead of footer button', () => {
const reviewPayload = {
can_proceed: true,
risk_briefs: [
{ level: 'low', title: '票据提示', content: '普通提示' }
],
confirmation_actions: [
{ action_type: 'save_draft', label: '保存为草稿' },
{ action_type: 'next_step', label: '继续下一步', emphasis: 'primary' }
]
}
const copy = buildReviewNextStepRichCopy(reviewPayload, { detailHref: '/app/documents/claim-1' })
const rendered = renderMarkdown(copy)
assert.match(copy, /系统识别您的单据已经填写完所有已知信息/)
assert.match(copy, /现存在 1 条低风险0 条中风险0 条高风险/)
assert.doesNotMatch(copy, /#review-risk-low/)
assert.doesNotMatch(copy, /#review-risk-medium/)
assert.doesNotMatch(copy, /#review-risk-high/)
assert.match(copy, /\[右侧\]\(#review-risk-panel\) 风险信息提示窗/)
assert.match(copy, /\[继续下一步\]\(#review-next-step\)/)
assert.match(copy, /\[快速修改单据信息\]\(\/app\/documents\/claim-1\)/)
assert.doesNotMatch(rendered, /markdown-risk-link-/)
assert.match(rendered, /<span class="markdown-risk-text-low">低风险<\/span>/)
assert.match(rendered, /<span class="markdown-risk-text-medium">中风险<\/span>/)
assert.match(rendered, /<span class="markdown-risk-text-high">高风险<\/span>/)
assert.doesNotMatch(rendered, /href="#review-risk-low"/)
assert.doesNotMatch(rendered, /href="#review-risk-medium"/)
assert.doesNotMatch(rendered, /href="#review-risk-high"/)
assert.match(rendered, /markdown-action-link-risk/)
assert.match(rendered, /markdown-action-link-next/)
assert.deepEqual(resolveReviewFooterActions(reviewPayload), [])
const highRiskCopy = buildReviewNextStepRichCopy(
{
...reviewPayload,
risk_briefs: [{ level: 'high', title: '金额超标' }]
},
{ detailHref: '/app/documents/claim-1' }
)
assert.doesNotMatch(highRiskCopy, /\[继续下一步\]\(#review-next-step\)/)
assert.match(createViewTemplateSurface, /class="review-next-step-rich-copy message-answer-markdown"[\s\S]*ui\.renderMarkdown\(ui\.buildReviewNextStepRichCopyForMessage\(message\)\)/)
assert.match(createViewTemplateSurface, /class="message-bubble"[\s\S]*:class="ui\.buildMessageBubbleClass\(message\)"/)
assert.match(createViewTemplateSurface, /:open="nextStepConfirmDialog\.open"[\s\S]*title="确认提交当前单据?"[\s\S]*confirm-text="确认提交"/)
assert.match(createViewScriptSurface, /const REVIEW_NEXT_STEP_HREF = '#review-next-step'/)
assert.match(createViewScriptSurface, /buildReviewRiskLevelCounts/)
assert.match(createViewScriptSurface, /function buildMessageBubbleClass\(message\)/)
assert.match(createViewScriptSurface, /message-bubble-review-risk-high/)
assert.match(createViewScriptSurface, /message-bubble-review-risk-medium/)
assert.match(createViewScriptSurface, /message-bubble-review-risk-low/)
assert.match(createViewScriptSurface, /function openReviewNextStepConfirm\(message\)/)
assert.match(createViewScriptSurface, /async function confirmReviewNextStepSubmit\(\)/)
assert.match(createViewScriptSurface, /href === REVIEW_NEXT_STEP_HREF[\s\S]*openReviewNextStepConfirm\(message\)/)
assert.match(createViewScriptSurface, /href\.startsWith\(REVIEW_RISK_PANEL_HREF_PREFIX\)[\s\S]*switchReviewDrawerMode\(REVIEW_DRAWER_MODE_RISK\)/)
assert.match(createViewBaseStyles, /\.message-bubble-review-risk-low\s*\{[\s\S]*border-color:\s*rgba\(37,\s*99,\s*235,/)
assert.match(createViewBaseStyles, /\.message-bubble-review-risk-medium\s*\{[\s\S]*border-color:\s*rgba\(217,\s*119,\s*6,/)
assert.match(createViewBaseStyles, /\.message-bubble-review-risk-high\s*\{[\s\S]*border-color:\s*rgba\(220,\s*38,\s*38,/)
assert.doesNotMatch(createViewBaseStyles, /markdown-risk-link-low/)
assert.match(createViewBaseStyles, /\.markdown-risk-text-low[\s\S]*color:\s*#2563eb/)
assert.match(createViewBaseStyles, /\.markdown-risk-text-medium[\s\S]*color:\s*#d97706/)
assert.match(createViewBaseStyles, /\.markdown-risk-text-high[\s\S]*color:\s*#dc2626/)
assert.match(createViewPart3Styles, /\.review-next-step-rich-copy\s*\{[\s\S]*margin-top:\s*30px;/)
})
test('review risk drawer lists risk briefs without score and posts details into the conversation', () => {
const riskItemsBlock = reviewPanelModelScript.match(/function buildReviewRiskItems\(reviewPayload\) \{[\s\S]*?\n\}\n\nexport function buildReviewRiskConversationText/)
assert.ok(riskItemsBlock, 'risk item builder should be present')
assert.doesNotMatch(createViewTemplateSurface, /review-side-risk-score/)
assert.doesNotMatch(createViewTemplateSurface, /风险评分/)
assert.doesNotMatch(createViewTemplateSurface, /暂无风险评分/)
assert.doesNotMatch(createViewScriptSurface, /function buildReviewRiskScore/)
assert.doesNotMatch(createViewScriptSurface, /const reviewRiskScore/)
assert.doesNotMatch(riskItemsBlock[0], /\.slice\(0,\s*6\)/)
assert.match(reviewPanelModelScript, /const DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS = \[[\s\S]*'历史报销画像'[\s\S]*'制度注意事项'/)
assert.match(
reviewPanelModelScript,
/function resolveReviewRiskBriefs\(reviewPayload\) \{[\s\S]*DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS\.some/
)
assert.match(
createViewTemplateSurface,
/class="review-side-risk-item"[\s\S]*@click="ui\.appendReviewRiskBriefToConversation\(item\)"/
)
assert.doesNotMatch(createViewTemplateSurface, /\{\{\s*item\.levelLabel\s*\}\}/)
assert.match(createViewTemplateSurface, /class="review-side-risk-icon" :title="item\.levelLabel"/)
assert.match(reviewPanelModelScript, /info:\s*\{[\s\S]*label:\s*'提示'/)
assert.match(reviewPanelModelScript, /medium:\s*\{[\s\S]*label:\s*'中风险'/)
assert.match(reviewPanelModelScript, /low:\s*\{[\s\S]*label:\s*'低风险'/)
assert.match(reviewPanelModelScript, /const isInfo = String\(item\?\.level \|\| ''\)\.trim\(\) === 'info'/)
assert.match(reviewPanelModelScript, /\$\{isInfo \? '提示内容' : '风险点'\}\$\{summary\}/)
assert.match(reviewPanelModelScript, /\$\{isInfo \? '处理建议' : '修改建议'\}\$\{suggestion\}/)
assert.match(reviewPanelModelScript, /function normalizeReviewRiskTitle/)
assert.match(reviewPanelModelScript, /\.replace\(\/AI\\s\*预审/)
assert.match(reviewPanelModelScript, /\.replace\(\/\(高风险\|中风险\|低风险\)\/g,\s*''\)/)
assert.match(reviewPanelModelScript, /sourceLabel:\s*meta\.label/)
assert.doesNotMatch(reviewPanelModelScript, /normalizedTitle\.includes\('AI预审'\)/)
assert.match(createViewScriptSurface, /metaTone:\s*item\.level \|\| 'low'/)
assert.doesNotMatch(createViewTemplateSurface, /@click="openReviewRiskDetail\(item\)"/)
assert.doesNotMatch(createViewTemplateSurface, /review-risk-detail-modal/)
assert.doesNotMatch(createViewScriptSurface, /reviewRiskDetailDialog/)
assert.doesNotMatch(createViewScriptSurface, /function openReviewRiskDetail/)
assert.match(
createViewScriptSurface,
/function appendReviewRiskBriefToConversation\(item\) \{[\s\S]*messages\.value\.push\(createMessage\('assistant'/
)
assert.match(reviewPanelModelScript, /function buildReviewRiskConversationText\(item, detailTarget = \{\}\)/)
assert.match(createViewScriptSurface, /function resolveReviewDetailTarget\(message = null\) \{[\s\S]*router\.resolve\(\{[\s\S]*name: 'app-document-detail'/)
assert.match(createViewScriptSurface, /function resolveReviewRiskDetailTarget\(\) \{[\s\S]*return resolveReviewDetailTarget\(\)/)
assert.match(createViewScriptSurface, /进入 \$\{claimNo\} 详情重新填写/)
assert.match(createViewTemplateSurface, /class="expense-query-risk-row"[\s\S]*ui\.appendExpenseQueryRiskToConversation\(record, risk\)/)
assert.match(createViewScriptSurface, /function appendExpenseQueryRiskToConversation\(record, risk\) \{[\s\S]*进入 \$\{claimNo\} 详情重新填写/)
})
test('review drawer default mode is scoped by the current action and travel overview uses travel-specific fields', () => {
assert.match(reviewDrawerScript, /activeReviewPanelScope/)
assert.match(reviewDrawerScript, /const reviewOverviewDrawerAvailable = computed\(\(\) => normalizedReviewPanelScope\.value === 'overview'\)/)
assert.match(reviewDrawerScript, /scope === 'documents' && hasDocuments[\s\S]*REVIEW_DRAWER_MODE_DOCUMENTS/)
assert.match(reviewDrawerScript, /scope === 'risk' && hasRisks[\s\S]*REVIEW_DRAWER_MODE_RISK/)
assert.match(reviewDrawerScript, /scope === 'overview'[\s\S]*REVIEW_DRAWER_MODE_REVIEW/)
assert.match(reviewPanelModelScript, /function normalizeReviewPanelScope\(scope\)/)
assert.match(createViewScriptSurface, /canExposeReviewPanelScope\(item\.reviewPanelScope\)/)
assert.match(createViewScriptSurface, /currentInsight\.value\.intent === 'agent' && agent[\s\S]*return null/)
assert.match(reviewPanelModelScript, /function isTravelReviewPayload\(reviewPayload/)
assert.match(reviewPanelModelScript, /function resolveReviewTravelTransportType\(reviewPayload/)
assert.match(reviewPanelModelScript, /label: '交通类型'[\s\S]*modelKey: 'transport_type'/)
assert.match(reviewPanelModelScript, /label: '酒店名称'[\s\S]*modelKey: 'merchant_name'/)
assert.match(reviewPanelModelScript, /label: '出差事宜'[\s\S]*editor: 'textarea'[\s\S]*wide: true/)
assert.match(createViewTemplateSurface, /item\.editor === 'textarea'[\s\S]*<textarea/)
assert.match(createViewTemplateSurface, /wide: item\.wide/)
})
test('submit composer scopes the side panel to intent overview, document upload, or triggered risk only', () => {
assert.match(submitComposerScript, /function resolveReviewPanelScope\(\{[\s\S]*reviewPayload = null/)
assert.match(submitComposerScript, /fileCount > 0 && documentCount > 0[\s\S]*return 'documents'/)
assert.match(submitComposerScript, /riskCount > 0 && \(asksRisk \|\| \['next_step', 'submit', 'submit_claim'\]\.includes\(normalizedAction\)\)[\s\S]*return 'risk'/)
assert.match(submitComposerScript, /!normalizedAction && fileCount === 0[\s\S]*return 'overview'/)
assert.match(submitComposerScript, /reviewPanelScope: stewardDelegated[\s\S]*resolveReviewPanelScope\(\{/)
assert.match(submitComposerScript, /nextInsight\.agent\.reviewPanelScope = assistantMessage\.reviewPanelScope/)
})
test('expense query answers keep one clear result structure with document center jump link', () => {
assert.doesNotMatch(createViewTemplateSurface, /message\.meta\?\.length/)
assert.match(createViewTemplateSurface, /!message\.reviewPayload && \(!message\.queryPayload \|\| message\.queryPayload\.selectionMode === 'reimbursement_application_association'\) && message\.suggestedActions\?\.length/)
assert.match(createViewTemplateSurface, /!message\.reviewPayload && !message\.queryPayload && message\.citations\?\.length/)
assert.match(createViewTemplateSurface, /message\.queryPayload\.title \|\| \(message\.queryPayload\.selectionMode === 'draft_association' \? '选择关联草稿' : '最近 5 条筛选结果'\)/)
assert.match(createViewTemplateSurface, /v-html="ui\.renderMarkdown\(ui\.buildExpenseQueryHint\(message\.queryPayload\)\)"/)
assert.match(createViewScriptSurface, /href\.startsWith\('\/app\/'\)[\s\S]*router\.push\(href\)/)
})
test('backend query response suppresses generic query actions and supports archived filter title', () => {
const responseScript = readFileSync(
fileURLToPath(new URL('../../server/src/app/services/user_agent_response.py', import.meta.url)),
'utf8'
)
const queryScript = readFileSync(
fileURLToPath(new URL('../../server/src/app/services/orchestrator_expense_query.py', import.meta.url)),
'utf8'
)
assert.match(responseScript, /if payload\.ontology\.intent in \{"query", "compare"\}:[\s\S]*return \[\]/)
assert.match(responseScript, /下面先列出最近 \{query_payload\.preview_count\} 条记录/)
assert.match(queryScript, /EXPENSE_QUERY_PREVIEW_LIMIT = 5/)
assert.match(queryScript, /"归档"[\s\S]*"archived"/)
assert.match(queryScript, /ExpenseClaim\.approval_stage\.ilike\("%归档%"\)/)
assert.match(queryScript, /"title": \([\s\S]*f"最近 \{len\(preview_claims\)\} 条\{scope_label\}"/)
})
test('closing the assistant while OCR is running defers unmount until the current flow finishes', () => {
assert.match(createViewScriptSurface, /const closeAfterBusy = ref\(false\)/)
assert.match(createViewScriptSurface, /function isWorkbenchBusy\(\) \{[\s\S]*submitting\.value \|\| reviewActionBusy\.value \|\| sessionSwitchBusy\.value/)
assert.match(createViewScriptSurface, /function maybeFinalizeDeferredClose\(\) \{[\s\S]*!closeAfterBusy\.value \|\| workbenchVisible\.value \|\| isWorkbenchBusy\(\)/)
assert.match(createViewScriptSurface, /function requestCloseWorkbench\(\) \{[\s\S]*closeAfterBusy\.value = isWorkbenchBusy\(\)[\s\S]*workbenchVisible\.value = false/)
assert.match(createViewScriptSurface, /function emitCloseAfterLeave\(\) \{[\s\S]*closeAfterBusy\.value && isWorkbenchBusy\(\)[\s\S]*return/)
assert.match(createViewScriptSurface, /\[submitting\.value, reviewActionBusy\.value, sessionSwitchBusy\.value, workbenchVisible\.value\][\s\S]*maybeFinalizeDeferredClose\(\)/)
})
test('composer exposes travel calculator and posts spreadsheet-backed result into conversation', () => {
assert.match(createViewTemplateSurface, /v-if="canShowTravelCalculator" class="travel-calculator-anchor"/)
assert.match(createViewTemplateSurface, /class="tool-btn composer-side-btn travel-calculator-trigger"[\s\S]*差旅计算器/)
assert.match(createViewTemplateSurface, /class="travel-calculator-popover"[\s\S]*v-model="travelCalculatorForm\.days"[\s\S]*v-model="travelCalculatorForm\.location"/)
assert.doesNotMatch(createViewTemplateSurface, /travel-calculator-modal/)
assert.doesNotMatch(createViewTemplateSurface, /travelCalculatorResult\.total_amount/)
assert.match(createViewScriptSurface, /calculateTravelReimbursement/)
assert.match(createViewScriptSurface, /const canShowTravelCalculator = computed\(\(\) => activeSessionType\.value === SESSION_TYPE_EXPENSE\)/)
assert.match(createViewScriptSurface, /function openTravelCalculator\(\) \{[\s\S]*!canShowTravelCalculator\.value[\s\S]*closeTravelCalculator\(\)/)
assert.match(createViewScriptSurface, /function toggleTravelCalculator\(\)/)
assert.match(createViewScriptSurface, /function toggleTravelCalculator\(\) \{[\s\S]*!canShowTravelCalculator\.value[\s\S]*closeTravelCalculator\(\)/)
assert.match(createViewScriptSurface, /watch\(canShowTravelCalculator,[\s\S]*closeTravelCalculator\(\)/)
assert.match(createViewScriptSurface, /function submitTravelCalculator\(\) \{[\s\S]*calculateTravelReimbursement\(\{[\s\S]*grade: String\(user\.grade/)
assert.match(createViewScriptSurface, /根据您输入的地点和天数/)
assert.match(createViewScriptSurface, /匹配到您要出差的地区为/)
assert.match(createViewScriptSurface, /参考可报销合计/)
assert.match(createViewScriptSurface, /住宿费:\$\{hotelRate\} × \$\{days\} = \$\{hotelAmount\} 元/)
assert.match(createViewScriptSurface, /messages\.value\.push\(createMessage\('assistant', buildTravelCalculatorResultText\(payload\)/)
assert.match(reimbursementService, /export function calculateTravelReimbursement\(payload = \{\}\) \{[\s\S]*\/reimbursements\/travel-calculator/)
})
test('continuing receipt upload preserves prior review form context', () => {
assert.match(reviewPanelModelScript, /function buildReviewFormContextFromPayload\(reviewPayload, inlineState = null\)/)
assert.match(reviewPanelModelScript, /function buildBusinessTimeContextFromReviewValues\(values = \{\}\)/)
assert.match(
submitComposerScript,
/resolvedUploadDisposition === 'continue_existing'[\s\S]*buildReviewFormContextFromPayload\([\s\S]*activeReviewPayload\.value[\s\S]*reviewInlineForm\.value[\s\S]*extraContext\.review_form_values/s
)
assert.match(
submitComposerScript,
/inheritedReviewContext\.business_time_context[\s\S]*extraContext\.business_time_context = inheritedReviewContext\.business_time_context/s
)
})
test('review form context emits ontology fields instead of local aliases', () => {
const context = buildReviewFormContextFromPayload(
{
edit_fields: [
{ key: 'expense_type', value: '' },
{ key: 'occurred_date', value: '' },
{ key: 'transport_type', value: '' },
{ key: 'reason', value: '' },
{ key: 'amount', value: '' },
{ key: 'business_location', value: '' },
{ key: 'attachment_names', value: '' }
]
},
{
expense_type: '差旅费',
occurred_date: '2026-06-01 至 2026-06-03',
transport_type: '火车',
reason_value: '支撑国网仿生产环境部署',
location: '上海',
amount: '3000',
attachment_names: 'ticket.pdf'
}
)
assert.equal(context.review_form_values.expense_type, '差旅费')
assert.equal(context.review_form_values.time_range, '2026-06-01 至 2026-06-03')
assert.equal(context.review_form_values.transport_mode, '火车')
assert.equal(context.review_form_values.reason, '支撑国网仿生产环境部署')
assert.equal(context.review_form_values.attachments, 'ticket.pdf')
assert.equal(context.review_form_values.occurred_date, undefined)
assert.equal(context.review_form_values.transport_type, undefined)
assert.equal(context.review_form_values.reason_value, undefined)
})
test('review drawer save action is disabled while receipt recognition is submitting', () => {
assert.match(createViewScriptSurface, /const submitting = ref\(false\)/)
assert.match(
submitComposerScript,
/submitting\.value = true[\s\S]*handleSubmitRecognitionFlow\(\{[\s\S]*recognizeOcrFiles[\s\S]*submitting\.value = false/s
)
assert.match(
submitComposerScript,
/collectReceiptFiles\(\{[\s\S]*files,[\s\S]*recognizeOcrFiles[\s\S]*\}\)/
)
assert.match(
createViewTemplateSurface,
/class="review-side-save-pill"[\s\S]*:disabled="ui\.reviewActionBusy \|\| ui\.submitting"[\s\S]*@click="ui\.saveInlineReviewChanges"/
)
assert.match(
createViewScriptSurface,
/async function handleReviewAction\(message, action\) \{[\s\S]*if \(!actionType \|\| submitting\.value \|\| reviewActionBusy\.value \|\| sessionSwitchBusy\.value\) return/
)
assert.match(
createViewScriptSurface,
/function saveInlineReviewChanges\(\) \{[\s\S]*\|\| submitting\.value[\s\S]*\|\| sessionSwitchBusy\.value[\s\S]*\) return/
)
})
test('flow run detail refresh has timeout so composer submit is not held open', () => {
assert.match(flowTimingScript, /FLOW_RUN_DETAIL_REFRESH_TIMEOUT_MS\s*=\s*3000/)
assert.match(
reimbursementFlowScript,
/await Promise\.race\(\[[\s\S]*fetchAgentRunDetail\(flowRunId\.value\)[\s\S]*globalThis\.setTimeout\(\(\) => resolve\(null\), FLOW_RUN_DETAIL_REFRESH_TIMEOUT_MS\)/
)
assert.match(reimbursementFlowScript, /if \(!run\) \{\s*return null\s*}/)
})
test('draft creation keeps detail-scoped attachment persistence alive before close', () => {
assert.match(
submitComposerScript,
/const persistComposerFilesToDraft = async \(\) => \{[\s\S]*const syncResult = await syncComposerFilesToDraft\(resolvedDraftClaimId, files\)[\s\S]*persistSessionState\(\)[\s\S]*if \(detailScopedUpload\) \{[\s\S]*emitRequestUpdated\?\.\(\{/s
)
assert.match(
submitComposerScript,
/const persistTask = persistComposerFilesToDraft\(\)[\s\S]*if \(detailScopedUpload\) \{[\s\S]*await persistTask[\s\S]*\} else \{[\s\S]*void persistTask[\s\S]*\}/s
)
assert.ok(
submitComposerScript.indexOf('replaceMessage(pendingMessage.id, assistantMessage)') <
submitComposerScript.indexOf('const persistTask = persistComposerFilesToDraft()'),
'assistant response should render before background attachment persistence starts'
)
assert.match(submitComposerScript, /source: 'detail-smart-entry-attachment-sync'/)
assert.match(submitComposerScript, /uploadedCount: Number\(syncResult\?\.uploadedCount \|\| 0\)/)
assert.match(attachmentSyncScript, /function normalizeAttachmentMatchName\(value\)/)
assert.match(attachmentSyncScript, /const normalizedMatchBuckets = new Map\(\)/)
assert.match(
attachmentSyncScript,
/nextExactMatch \|\| nextNormalizedMatch \|\| fallbackMatch \|\| emptyFallbackMatch/
)
})
test('detail smart-entry receipt sync uploads files to existing empty items and creates a row when needed', async () => {
const uploadCalls = []
let createCount = 0
const claimSnapshots = [
{
items: [
{ id: 'item-empty-1', item_type: 'hotel_ticket', invoice_id: '' },
{ id: 'item-persisted', item_type: 'train_ticket', invoice_id: 'claim-1/item-persisted/old.pdf' }
]
},
{
items: [
{ id: 'item-empty-1', item_type: 'hotel_ticket', invoice_id: '' },
{ id: 'item-persisted', item_type: 'train_ticket', invoice_id: 'claim-1/item-persisted/old.pdf' }
]
}
]
const attachments = useTravelReimbursementAttachments({
isKnowledgeSession: ref(false),
reviewFilePreviews: ref([]),
linkedRequest: ref({}),
draftClaimId: ref('claim-1'),
activeReviewPayload: ref(null),
reviewInlinePendingFiles: ref([]),
reviewInlineForm: ref({}),
reviewInlineEditorKey: ref(''),
composerUploadIntent: ref(''),
submitting: ref(false),
reviewActionBusy: ref(false),
toast: () => {},
fileInputRef: ref(null),
createExpenseClaimItem: async () => {
createCount += 1
return {
items: [
{ id: 'item-created-1', item_type: 'taxi_receipt', invoice_id: '' }
]
}
},
fetchExpenseClaimDetail: async () => claimSnapshots.shift() || { items: [] },
fetchExpenseClaimItemAttachmentMeta: async () => null,
fetchExpenseClaimAttachmentAsset: async () => new Blob(['preview']),
uploadExpenseClaimItemAttachment: async (claimId, itemId, file) => {
uploadCalls.push({ claimId, itemId, fileName: file.name })
return {}
},
extractReviewAttachmentNames: () => [],
mergeFilesWithLimit: (existing, incoming) => ({ files: [...existing, ...incoming], overflowCount: 0 }),
mergeFilePreviews: (existing, incoming) => [...existing, ...incoming],
isTemporaryPreviewUrl: () => false,
resolveAttachmentPreviewKind: () => '',
resolveDocumentPreview: () => null,
buildFilePreviews: () => [],
buildFileIdentity: (file) => file.name,
MAX_ATTACHMENTS: 5,
VISIBLE_ATTACHMENT_CHIPS: 3,
clearInlineReviewFieldError: () => {}
})
const result = await attachments.syncComposerFilesToDraft('claim-1', [
{ name: 'hotel.pdf' },
{ name: 'taxi.pdf' }
])
assert.deepEqual(uploadCalls, [
{ claimId: 'claim-1', itemId: 'item-empty-1', fileName: 'hotel.pdf' },
{ claimId: 'claim-1', itemId: 'item-created-1', fileName: 'taxi.pdf' }
])
assert.equal(createCount, 1)
assert.equal(result.uploadedCount, 2)
assert.equal(result.skippedCount, 0)
})
test('review summary renders markdown and save draft relies on backend response only', () => {
assert.match(
createViewTemplateSurface,
/message\.text && message\.role === 'assistant' && message\.reviewPayload[\s\S]*v-html="ui\.renderMarkdown\(ui\.buildReviewMainMessageText\(message\)\)"/
)
assert.doesNotMatch(
reviewActionsScript,
/messages\.value\.push\(\s*createMessage\('assistant', actionConfig\.successMessage/
)
})
test('saved draft review messages stop showing the save-draft prompt', () => {
const reviewPayload = {
slot_cards: [
{ key: 'amount', label: '金额', title: '金额', status: 'missing', required: true },
{ key: 'attachments', label: '票据状态', title: '票据状态', status: 'missing', required: true }
],
missing_slots: ['金额', '票据附件'],
risk_briefs: [],
confirmation_actions: [
{ label: '保存为草稿', action_type: 'save_draft' }
]
}
const followup = buildReviewPlainFollowupCopy(reviewPayload, { savedDraft: true })
assert.equal(followup.lead, '后续处理:')
assert.match(followup.summary, /自动检测/)
assert.match(followup.summary, /继续上传/)
assert.equal(followup.items.length, 0)
assert.doesNotMatch(followup.summary, /当前草稿待完善|必须/)
assert.doesNotMatch(followup.summary, /点击|点“草稿”|保存为草稿|临时保存|暂存/)
assert.match(messageItemTemplate, /buildReviewPlainFollowupForMessage\(message\)/)
assert.match(createViewScriptSurface, /function isDraftSavedReviewMessage\(message\)/)
assert.match(createViewScriptSurface, /function canUseInlineSaveDraft\(message\)[\s\S]*isDraftSavedReviewMessage\(message\)/)
})
test('guided save draft emits refresh and exposes reimbursement draft detail card', () => {
assert.match(
createViewScriptSurface,
/emitDraftSaved:\s*\(payload\)\s*=>\s*emit\('draft-saved', payload\)/
)
assert.match(submitComposerScript, /function emitSavedDraftRefresh\(draftPayload\)/)
assert.match(
submitComposerScript,
/emitSavedDraftRefresh\(payload\?\.result\?\.draft_payload \|\| null\)/
)
assert.match(createViewScriptSurface, /function shouldShowDraftSavedCard\(message\)/)
assert.match(createViewScriptSurface, /function canOpenDraftDetail\(message\)/)
assert.match(createViewScriptSurface, /function resolveReimbursementDraftClaimNo\(draftPayload\)/)
assert.doesNotMatch(createViewScriptSurface, /function buildReimbursementDraftSummaryItems\(draftPayload\)/)
assert.match(messageItemTemplate, /v-if="ui\.canOpenDraftDetail\(message\)"/)
assert.match(messageItemTemplate, /class="reimbursement-draft-link"/)
assert.match(messageItemTemplate, /reimbursement-draft-pending-detail/)
})