2026-05-20 21:00:47 +08:00
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
2026-05-30 15:46:51 +08:00
import { ref } from 'vue'
2026-05-20 21:00:47 +08:00
2026-05-22 23:47:28 +08:00
import {
2026-06-03 15:46:56 +08:00
buildReviewFormContextFromPayload ,
2026-05-22 23:47:28 +08:00
buildLocallySyncedReviewPayload ,
buildReviewNextStepRichCopy ,
buildReviewPlainFollowupCopy ,
isTravelReviewPayload ,
resolveReviewFooterActions
} from '../src/views/scripts/travelReimbursementReviewModel.js'
2026-05-30 15:46:51 +08:00
import { useTravelReimbursementAttachments } from '../src/views/scripts/useTravelReimbursementAttachments.js'
2026-05-22 23:47:28 +08:00
import { renderMarkdown } from '../src/utils/markdown.js'
2026-05-22 16:00:19 +08:00
2026-05-20 21:00:47 +08:00
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'
)
2026-06-22 11:58:53 +08:00
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' )
2026-06-13 14:52:26 +00:00
const reviewPanelModelScript = readFileSync (
fileURLToPath ( new URL ( '../src/views/scripts/travelReimbursementReviewPanelModel.js' , import . meta . url ) ) ,
'utf8'
)
2026-06-23 11:21:18 +08:00
const createReviewModelScript = readFileSync (
fileURLToPath ( new URL ( '../src/views/scripts/travelReimbursementCreateReviewModel.js' , import . meta . url ) ) ,
'utf8'
)
2026-06-02 14:01:51 +08:00
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'
)
2026-06-22 11:58:53 +08:00
const createViewTemplateSurface = [
createViewTemplate ,
messageItemTemplate ,
insightPanelTemplate
] . join ( '\n' )
2026-05-21 09:28:33 +08:00
const reimbursementService = readFileSync (
fileURLToPath ( new URL ( '../src/services/reimbursements.js' , import . meta . url ) ) ,
'utf8'
)
2026-06-02 14:01:51 +08:00
const reimbursementFlowScript = readFileSync (
fileURLToPath ( new URL ( '../src/views/scripts/useTravelReimbursementFlow.js' , import . meta . url ) ) ,
'utf8'
)
2026-06-22 11:58:53 +08:00
const flowTimingScript = readFileSync (
fileURLToPath ( new URL ( '../src/views/scripts/travelReimbursementFlowTiming.js' , import . meta . url ) ) ,
'utf8'
)
2026-05-21 23:53:03 +08:00
const reviewActionsScript = readFileSync (
fileURLToPath ( new URL ( '../src/views/scripts/useTravelReimbursementReviewActions.js' , import . meta . url ) ) ,
'utf8'
)
2026-05-22 16:00:19 +08:00
const reviewDrawerScript = readFileSync (
fileURLToPath ( new URL ( '../src/views/scripts/useTravelReimbursementReviewDrawer.js' , import . meta . url ) ) ,
'utf8'
)
2026-06-22 11:58:53 +08:00
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' )
2026-05-21 23:53:03 +08:00
const attachmentsScript = readFileSync (
fileURLToPath ( new URL ( '../src/views/scripts/useTravelReimbursementAttachments.js' , import . meta . url ) ) ,
'utf8'
)
2026-06-22 11:58:53 +08:00
const attachmentSyncScript = readFileSync (
fileURLToPath ( new URL ( '../src/utils/expenseClaimAttachmentSync.js' , import . meta . url ) ) ,
'utf8'
)
2026-05-22 23:47:28 +08:00
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'
)
2026-06-02 14:01:51 +08:00
const insightPanelStyles = readFileSync (
fileURLToPath ( new URL ( '../src/assets/styles/components/travel-reimbursement-insight-panel.css' , import . meta . url ) ) ,
'utf8'
)
2026-05-20 21:00:47 +08:00
test ( 'review drawer tools expose the default review tab before conditional document and risk tabs' , ( ) => {
2026-06-22 11:58:53 +08:00
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="调用流程"/ )
2026-05-20 21:00:47 +08:00
assert . ok (
2026-06-22 11:58:53 +08:00
createViewTemplateSurface . indexOf ( 'title="报销识别核对"' ) < createViewTemplateSurface . indexOf ( 'title="单据识别"' ) ,
2026-05-20 21:00:47 +08:00
'default review button should be placed before the document recognition button'
)
} )
2026-06-23 11:21:18 +08:00
test ( 'create review model remains a thin compatibility layer over review panel model' , ( ) => {
assert . match ( createReviewModelScript , /export \{[\s\S]*buildReviewFactCards[\s\S]*buildReviewRiskItems[\s\S]*\} from '\.\/travelReimbursementReviewPanelModel\.js'/ )
assert . doesNotMatch ( createReviewModelScript , /function buildReviewFactCards/ )
assert . doesNotMatch ( createReviewModelScript , /function buildReviewRiskItems/ )
assert . doesNotMatch ( createReviewModelScript , /const REVIEW_RISK_LEVEL_META/ )
} )
2026-05-20 21:00:47 +08:00
test ( 'review drawer tool buttons switch modes instead of toggling the active mode closed' , ( ) => {
2026-06-22 11:58:53 +08:00
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/ )
2026-05-20 21:00:47 +08:00
} )
2026-05-21 09:28:33 +08:00
2026-05-22 23:47:28 +08:00
test ( 'document review drawer fills sidebar height and preview dialog is centered' , ( ) => {
2026-06-22 11:58:53 +08:00
assert . match ( createViewTemplateSurface , /class="insight-body"[\s\S]*:class="\{ 'document-review-body': ui\.isReviewDocumentDrawer \}"/ )
2026-05-22 23:47:28 +08:00
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;/ )
} )
2026-06-06 17:19:07 +08:00
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%;/ )
} )
2026-06-02 14:01:51 +08:00
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;/ )
} )
2026-05-22 23:47:28 +08:00
test ( 'document preview avoids restored stale object urls' , ( ) => {
2026-06-22 11:58:53 +08:00
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"/ )
2026-05-22 23:47:28 +08:00
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 )
2026-06-13 14:52:26 +00:00
assert . match ( reviewPanelModelScript , /shouldShowReviewFactCard\(reviewPayload, 'customer_name'/ )
2026-05-22 23:47:28 +08:00
assert . doesNotMatch (
2026-06-13 14:52:26 +00:00
reviewPanelModelScript ,
2026-05-22 23:47:28 +08:00
/key:\s*'customer_name'[\s\S]{0,220}placeholder:\s*'请输入客户名称'[\s\S]{0,80}\},\s*\{[\s\S]{0,80}key:\s*'attachments'/
)
2026-06-22 11:58:53 +08:00
assert . match ( createViewTemplateSurface , /placeholder="例如:出租车\/网约车票据 \/ 火车\/高铁票"/ )
assert . doesNotMatch ( createViewTemplateSurface , /票据场景[\s\S]{0,260}例如:业务招待费 \/ 差旅费/ )
2026-05-22 23:47:28 +08:00
} )
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' }
]
}
2026-05-26 09:15:14 +08:00
const copy = buildReviewNextStepRichCopy ( reviewPayload , { detailHref : '/app/documents/claim-1' } )
2026-05-22 23:47:28 +08:00
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\)/ )
2026-05-26 09:15:14 +08:00
assert . match ( copy , /\[快速修改单据信息\]\(\/app\/documents\/claim-1\)/ )
2026-05-22 23:47:28 +08:00
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 : '金额超标' } ]
} ,
2026-05-26 09:15:14 +08:00
{ detailHref : '/app/documents/claim-1' }
2026-05-22 23:47:28 +08:00
)
assert . doesNotMatch ( highRiskCopy , /\[继续下一步\]\(#review-next-step\)/ )
2026-06-22 11:58:53 +08:00
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\)/ )
2026-05-22 23:47:28 +08:00
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;/ )
} )
2026-05-21 09:28:33 +08:00
test ( 'review risk drawer lists risk briefs without score and posts details into the conversation' , ( ) => {
2026-06-13 14:52:26 +00:00
const riskItemsBlock = reviewPanelModelScript . match ( /function buildReviewRiskItems\(reviewPayload\) \{[\s\S]*?\n\}\n\nexport function buildReviewRiskConversationText/ )
2026-05-21 09:28:33 +08:00
assert . ok ( riskItemsBlock , 'risk item builder should be present' )
2026-06-22 11:58:53 +08:00
assert . doesNotMatch ( createViewTemplateSurface , /review-side-risk-score/ )
assert . doesNotMatch ( createViewTemplateSurface , /风险评分/ )
assert . doesNotMatch ( createViewTemplateSurface , /暂无风险评分/ )
assert . doesNotMatch ( createViewScriptSurface , /function buildReviewRiskScore/ )
assert . doesNotMatch ( createViewScriptSurface , /const reviewRiskScore/ )
2026-05-21 09:28:33 +08:00
assert . doesNotMatch ( riskItemsBlock [ 0 ] , /\.slice\(0,\s*6\)/ )
2026-06-13 14:52:26 +00:00
assert . match ( reviewPanelModelScript , /const DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS = \[[\s\S]*'历史报销画像'[\s\S]*'制度注意事项'/ )
2026-05-21 09:28:33 +08:00
assert . match (
2026-06-13 14:52:26 +00:00
reviewPanelModelScript ,
2026-05-21 09:28:33 +08:00
/function resolveReviewRiskBriefs\(reviewPayload\) \{[\s\S]*DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS\.some/
)
assert . match (
2026-06-22 11:58:53 +08:00
createViewTemplateSurface ,
/class="review-side-risk-item"[\s\S]*@click="ui\.appendReviewRiskBriefToConversation\(item\)"/
2026-05-21 09:28:33 +08:00
)
2026-06-22 11:58:53 +08:00
assert . doesNotMatch ( createViewTemplateSurface , /\{\{\s*item\.levelLabel\s*\}\}/ )
assert . match ( createViewTemplateSurface , /class="review-side-risk-icon" :title="item\.levelLabel"/ )
2026-06-13 14:52:26 +00:00
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预审'\)/ )
2026-06-22 11:58:53 +08:00
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/ )
2026-05-21 09:28:33 +08:00
assert . match (
2026-06-22 11:58:53 +08:00
createViewScriptSurface ,
2026-05-21 09:28:33 +08:00
/function appendReviewRiskBriefToConversation\(item\) \{[\s\S]*messages\.value\.push\(createMessage\('assistant'/
)
2026-06-13 14:52:26 +00:00
assert . match ( reviewPanelModelScript , /function buildReviewRiskConversationText\(item, detailTarget = \{\}\)/ )
2026-06-22 11:58:53 +08:00
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\} 详情重新填写/ )
2026-05-21 09:28:33 +08:00
} )
2026-05-22 16:00:19 +08:00
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/ )
2026-06-13 14:52:26 +00:00
assert . match ( reviewPanelModelScript , /function normalizeReviewPanelScope\(scope\)/ )
2026-06-22 11:58:53 +08:00
assert . match ( createViewScriptSurface , /canExposeReviewPanelScope\(item\.reviewPanelScope\)/ )
assert . match ( createViewScriptSurface , /currentInsight\.value\.intent === 'agent' && agent[\s\S]*return null/ )
2026-06-13 14:52:26 +00:00
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/ )
2026-06-22 11:58:53 +08:00
assert . match ( createViewTemplateSurface , /item\.editor === 'textarea'[\s\S]*<textarea/ )
assert . match ( createViewTemplateSurface , /wide: item\.wide/ )
2026-05-21 09:28:33 +08:00
} )
2026-05-22 16:00:19 +08:00
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'/ )
2026-06-22 11:58:53 +08:00
assert . match ( submitComposerScript , /reviewPanelScope: stewardDelegated[\s\S]*resolveReviewPanelScope\(\{/ )
2026-05-22 16:00:19 +08:00
assert . match ( submitComposerScript , /nextInsight\.agent\.reviewPanelScope = assistantMessage\.reviewPanelScope/ )
} )
2026-05-26 09:15:14 +08:00
test ( 'expense query answers keep one clear result structure with document center jump link' , ( ) => {
2026-06-22 11:58:53 +08:00
assert . doesNotMatch ( createViewTemplateSurface , /message\.meta\?\.length/ )
2026-06-22 15:56:06 +08:00
assert . match ( createViewTemplateSurface , /!message\.reviewPayload && \(!message\.queryPayload \|\| message\.queryPayload\.selectionMode === 'reimbursement_application_association'\) && message\.suggestedActions\?\.length/ )
2026-06-22 11:58:53 +08:00
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\)/ )
2026-05-22 16:00:19 +08:00
} )
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\("%归档%"\)/ )
2026-06-22 11:58:53 +08:00
assert . match ( queryScript , /"title": \([\s\S]*f"最近 \{len\(preview_claims\)\} 条\{scope_label\}"/ )
2026-05-22 16:00:19 +08:00
} )
test ( 'closing the assistant while OCR is running defers unmount until the current flow finishes' , ( ) => {
2026-06-22 11:58:53 +08:00
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\(\)/ )
2026-05-22 16:00:19 +08:00
} )
2026-05-21 09:28:33 +08:00
test ( 'composer exposes travel calculator and posts spreadsheet-backed result into conversation' , ( ) => {
2026-06-22 11:58:53 +08:00
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\)/ )
2026-05-21 09:28:33 +08:00
assert . match ( reimbursementService , /export function calculateTravelReimbursement\(payload = \{\}\) \{[\s\S]*\/reimbursements\/travel-calculator/ )
} )
2026-05-21 14:24:51 +08:00
test ( 'continuing receipt upload preserves prior review form context' , ( ) => {
2026-06-13 14:52:26 +00:00
assert . match ( reviewPanelModelScript , /function buildReviewFormContextFromPayload\(reviewPayload, inlineState = null\)/ )
assert . match ( reviewPanelModelScript , /function buildBusinessTimeContextFromReviewValues\(values = \{\}\)/ )
2026-05-21 14:24:51 +08:00
assert . match (
2026-06-22 11:58:53 +08:00
submitComposerScript ,
2026-05-21 14:24:51 +08:00
/ r e s o l v e d U p l o a d D i s p o s i t i o n = = = ' c o n t i n u e _ e x i s t i n g ' [ \ s \ S ] * b u i l d R e v i e w F o r m C o n t e x t F r o m P a y l o a d \ ( [ \ s \ S ] * a c t i v e R e v i e w P a y l o a d \ . v a l u e [ \ s \ S ] * r e v i e w I n l i n e F o r m \ . v a l u e [ \ s \ S ] * e x t r a C o n t e x t \ . r e v i e w _ f o r m _ v a l u e s / s
)
assert . match (
2026-06-22 11:58:53 +08:00
submitComposerScript ,
2026-05-21 14:24:51 +08:00
/ i n h e r i t e d R e v i e w C o n t e x t \ . b u s i n e s s _ t i m e _ c o n t e x t [ \ s \ S ] * e x t r a C o n t e x t \ . b u s i n e s s _ t i m e _ c o n t e x t = i n h e r i t e d R e v i e w C o n t e x t \ . b u s i n e s s _ t i m e _ c o n t e x t / s
)
} )
2026-06-03 15:46:56 +08:00
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 )
} )
2026-05-21 14:24:51 +08:00
test ( 'review drawer save action is disabled while receipt recognition is submitting' , ( ) => {
2026-06-22 11:58:53 +08:00
assert . match ( createViewScriptSurface , /const submitting = ref\(false\)/ )
assert . match (
submitComposerScript ,
/ s u b m i t t i n g \ . v a l u e = t r u e [ \ s \ S ] * h a n d l e S u b m i t R e c o g n i t i o n F l o w \ ( \ { [ \ s \ S ] * r e c o g n i z e O c r F i l e s [ \ s \ S ] * s u b m i t t i n g \ . v a l u e = f a l s e / s
)
2026-05-21 14:24:51 +08:00
assert . match (
2026-06-22 11:58:53 +08:00
submitComposerScript ,
/collectReceiptFiles\(\{[\s\S]*files,[\s\S]*recognizeOcrFiles[\s\S]*\}\)/
2026-05-21 14:24:51 +08:00
)
assert . match (
2026-06-22 11:58:53 +08:00
createViewTemplateSurface ,
/class="review-side-save-pill"[\s\S]*:disabled="ui\.reviewActionBusy \|\| ui\.submitting"[\s\S]*@click="ui\.saveInlineReviewChanges"/
2026-05-21 14:24:51 +08:00
)
assert . match (
2026-06-22 11:58:53 +08:00
createViewScriptSurface ,
2026-05-21 14:24:51 +08:00
/async function handleReviewAction\(message, action\) \{[\s\S]*if \(!actionType \|\| submitting\.value \|\| reviewActionBusy\.value \|\| sessionSwitchBusy\.value\) return/
)
assert . match (
2026-06-22 11:58:53 +08:00
createViewScriptSurface ,
2026-05-21 14:24:51 +08:00
/function saveInlineReviewChanges\(\) \{[\s\S]*\|\| submitting\.value[\s\S]*\|\| sessionSwitchBusy\.value[\s\S]*\) return/
)
} )
2026-05-21 23:53:03 +08:00
2026-06-02 14:01:51 +08:00
test ( 'flow run detail refresh has timeout so composer submit is not held open' , ( ) => {
2026-06-22 11:58:53 +08:00
assert . match ( flowTimingScript , /FLOW_RUN_DETAIL_REFRESH_TIMEOUT_MS\s*=\s*3000/ )
2026-06-02 14:01:51 +08:00
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*}/ )
} )
2026-06-01 17:07:14 +08:00
test ( 'draft creation keeps detail-scoped attachment persistence alive before close' , ( ) => {
2026-05-21 23:53:03 +08:00
assert . match (
submitComposerScript ,
2026-06-01 17:07:14 +08:00
/ c o n s t p e r s i s t C o m p o s e r F i l e s T o D r a f t = a s y n c \ ( \ ) = > \ { [ \ s \ S ] * c o n s t s y n c R e s u l t = a w a i t s y n c C o m p o s e r F i l e s T o D r a f t \ ( r e s o l v e d D r a f t C l a i m I d , f i l e s \ ) [ \ s \ S ] * p e r s i s t S e s s i o n S t a t e \ ( \ ) [ \ s \ S ] * i f \ ( d e t a i l S c o p e d U p l o a d \ ) \ { [ \ s \ S ] * e m i t R e q u e s t U p d a t e d \ ? \ . \ ( \ { / s
2026-05-21 23:53:03 +08:00
)
2026-06-01 17:07:14 +08:00
assert . match (
2026-05-21 23:53:03 +08:00
submitComposerScript ,
2026-06-01 17:07:14 +08:00
/ c o n s t p e r s i s t T a s k = p e r s i s t C o m p o s e r F i l e s T o D r a f t \ ( \ ) [ \ s \ S ] * i f \ ( d e t a i l S c o p e d U p l o a d \ ) \ { [ \ s \ S ] * a w a i t p e r s i s t T a s k [ \ s \ S ] * \ } e l s e \ { [ \ s \ S ] * v o i d p e r s i s t T a s k [ \ s \ S ] * \ } / s
2026-05-21 23:53:03 +08:00
)
assert . ok (
2026-05-22 08:58:59 +08:00
submitComposerScript . indexOf ( 'replaceMessage(pendingMessage.id, assistantMessage)' ) <
2026-06-01 17:07:14 +08:00
submitComposerScript . indexOf ( 'const persistTask = persistComposerFilesToDraft()' ) ,
2026-05-22 08:58:59 +08:00
'assistant response should render before background attachment persistence starts'
2026-05-21 23:53:03 +08:00
)
2026-06-01 17:07:14 +08:00
assert . match ( submitComposerScript , /source: 'detail-smart-entry-attachment-sync'/ )
assert . match ( submitComposerScript , /uploadedCount: Number\(syncResult\?\.uploadedCount \|\| 0\)/ )
2026-06-22 11:58:53 +08:00
assert . match ( attachmentSyncScript , /function normalizeAttachmentMatchName\(value\)/ )
assert . match ( attachmentSyncScript , /const normalizedMatchBuckets = new Map\(\)/ )
2026-05-21 23:53:03 +08:00
assert . match (
2026-06-22 11:58:53 +08:00
attachmentSyncScript ,
2026-05-30 15:46:51 +08:00
/nextExactMatch \|\| nextNormalizedMatch \|\| fallbackMatch \|\| emptyFallbackMatch/
2026-05-21 23:53:03 +08:00
)
} )
2026-05-30 15:46:51 +08:00
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 )
} )
2026-05-21 23:53:03 +08:00
test ( 'review summary renders markdown and save draft relies on backend response only' , ( ) => {
assert . match (
2026-06-22 11:58:53 +08:00
createViewTemplateSurface ,
/message\.text && message\.role === 'assistant' && message\.reviewPayload[\s\S]*v-html="ui\.renderMarkdown\(ui\.buildReviewMainMessageText\(message\)\)"/
2026-05-21 23:53:03 +08:00
)
assert . doesNotMatch (
reviewActionsScript ,
/messages\.value\.push\(\s*createMessage\('assistant', actionConfig\.successMessage/
)
} )
2026-05-22 16:00:19 +08:00
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 } )
2026-06-02 14:01:51 +08:00
assert . equal ( followup . lead , '后续处理:' )
assert . match ( followup . summary , /自动检测/ )
assert . match ( followup . summary , /继续上传/ )
assert . equal ( followup . items . length , 0 )
assert . doesNotMatch ( followup . summary , /当前草稿待完善|必须/ )
2026-05-22 16:00:19 +08:00
assert . doesNotMatch ( followup . summary , /点击|点“草稿”|保存为草稿|临时保存|暂存/ )
2026-06-02 14:01:51 +08:00
assert . match ( messageItemTemplate , /buildReviewPlainFollowupForMessage\(message\)/ )
2026-06-22 11:58:53 +08:00
assert . match ( createViewScriptSurface , /function isDraftSavedReviewMessage\(message\)/ )
assert . match ( createViewScriptSurface , /function canUseInlineSaveDraft\(message\)[\s\S]*isDraftSavedReviewMessage\(message\)/ )
2026-05-22 16:00:19 +08:00
} )
2026-06-02 14:01:51 +08:00
test ( 'guided save draft emits refresh and exposes reimbursement draft detail card' , ( ) => {
assert . match (
2026-06-22 11:58:53 +08:00
createViewScriptSurface ,
2026-06-02 14:01:51 +08:00
/emitDraftSaved:\s*\(payload\)\s*=>\s*emit\('draft-saved', payload\)/
)
assert . match ( submitComposerScript , /function emitSavedDraftRefresh\(draftPayload\)/ )
assert . match (
submitComposerScript ,
/emitSavedDraftRefresh\(payload\?\.result\?\.draft_payload \|\| null\)/
)
2026-06-22 11:58:53 +08:00
assert . match ( createViewScriptSurface , /function shouldShowDraftSavedCard\(message\)/ )
assert . match ( createViewScriptSurface , /function canOpenDraftDetail\(message\)/ )
assert . match ( createViewScriptSurface , /function resolveReimbursementDraftClaimNo\(draftPayload\)/ )
assert . doesNotMatch ( createViewScriptSurface , /function buildReimbursementDraftSummaryItems\(draftPayload\)/ )
2026-06-06 17:19:07 +08:00
assert . match ( messageItemTemplate , /v-if="ui\.canOpenDraftDetail\(message\)"/ )
2026-06-02 14:01:51 +08:00
assert . match ( messageItemTemplate , /class="reimbursement-draft-link"/ )
2026-06-06 17:19:07 +08:00
assert . match ( messageItemTemplate , /reimbursement-draft-pending-detail/ )
2026-06-02 14:01:51 +08:00
} )