import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' import { fileURLToPath } from 'node:url' import { buildExpenseSceneSelectionActions } from '../src/utils/expenseAssistantActions.js' import { buildExpenseSceneSelectionMessage } from '../src/views/scripts/travelReimbursementConversationModel.js' const aiMode = readFileSync( fileURLToPath(new URL('../src/components/business/PersonalWorkbenchAiMode.vue', import.meta.url)), 'utf8' ) test('expense scene selection message asks for type first and mentions application gate', () => { const text = buildExpenseSceneSelectionMessage('帮我发起一笔报销,并检查需要准备哪些票据材料。') assert.match(text, /先选|选择.*报销类型|报销场景/) assert.match(text, /差旅|招待|申请单|关联申请单/) }) test('expense scene actions mark travel and meal as requiring application', () => { const actions = buildExpenseSceneSelectionActions('帮我发起一笔报销,并检查需要准备哪些票据材料。') const travel = actions.find((action) => action.payload.expense_type === 'travel') const meal = actions.find((action) => action.payload.expense_type === 'meal') const transport = actions.find((action) => action.payload.expense_type === 'transport') assert.equal(travel.payload.requires_application_before_reimbursement, true) assert.equal(travel.payload.next_session_type, 'application') assert.equal(meal.payload.requires_application_before_reimbursement, true) assert.equal(meal.payload.next_session_type, 'application') assert.equal(transport.payload.requires_application_before_reimbursement, false) assert.equal(transport.payload.next_session_type, 'expense') }) test('AI mode quick reimbursement card opens scene selection before steward plan', () => { assert.match( aiMode, /function runAiModeAction\(item\) {[\s\S]{0,220}pushInlineExpenseSceneSelectionPrompt\(item\.prompt, item\.label\)/ ) }) test('AI mode expense scene selection stays in the inline conversation without opening the create view', () => { assert.match(aiMode, /actionType === 'select_expense_type'/) assert.doesNotMatch(aiMode, /emit\('open-assistant'/) }) test('AI mode offers an inline application shortcut when no candidate application exists', () => { assert.match(aiMode, /!candidates\.length/) assert.match(aiMode, /ai_application_start_inline/) assert.match(aiMode, /buildRequiredApplicationMissingText/) assert.match(aiMode, /function startAiApplicationPreview/) assert.match(aiMode, /buildLocalApplicationPreview/) assert.match(aiMode, /buildLocalApplicationPreviewMessage/) assert.match(aiMode, /refreshApplicationPreviewEstimate/) assert.match(aiMode, /applicationPreview:\s*preview/) assert.match(aiMode, /suggestedActions:\s*buildInlineApplicationPreviewSuggestedActions\(preview\)/) assert.doesNotMatch(aiMode, /function startAiApplicationDraft/) assert.doesNotMatch(aiMode, /buildAiApplicationStepPrompt/) }) test('AI mode steward reimbursement action opens expense scene selection locally', () => { assert.match(aiMode, /buildExpenseSceneSelectionMessage/) assert.match(aiMode, /buildExpenseSceneSelectionActions/) assert.match(aiMode, /SESSION_TYPE_EXPENSE/) assert.match(aiMode, /function pushInlineExpenseSceneSelectionPrompt/) assert.match(aiMode, /payload\?\.session_type[\s\S]*SESSION_TYPE_EXPENSE/) assert.match(aiMode, /pushInlineExpenseSceneSelectionPrompt\(carryText, action\.label\)/) assert.match( aiMode, /SESSION_TYPE_EXPENSE[\s\S]{0,140}pushInlineExpenseSceneSelectionPrompt\(carryText, action\.label\)[\s\S]{0,40}return/ ) }) test('AI mode attaches required application lookup result before steward planning', () => { assert.match(aiMode, /async function attachAiRequiredApplicationGate\(planRequest, prompt\)/) assert.match(aiMode, /fetchExpenseClaims\(\)/) assert.match(aiMode, /filterRequiredApplicationCandidates\(claims, 'travel', currentUser\.value \|\| \{\}\)/) assert.match(aiMode, /required_application_gate/) assert.match(aiMode, /await attachAiRequiredApplicationGate\(planRequest, prompt\)/) }) test('AI mode handles document query prompts locally before steward planning', () => { assert.match(aiMode, /resolveAiDocumentQueryIntent\(prompt/) assert.match(aiMode, /async function handleAiDocumentQueryIntent/) assert.match(aiMode, /buildAiDocumentQueryConditionSummary/) assert.match(aiMode, /filterAiDocumentQueryRecords\(payload, intent\)/) assert.match(aiMode, /fetchApprovalExpenseClaims/) assert.match(aiMode, /buildAiDocumentQueryMessage/) assert.match(aiMode, /AI_DOCUMENT_QUERY_STEP_DELAY_MS/) assert.match(aiMode, /async function updateAiDocumentQueryThinking/) assert.match(aiMode, /解析自然语言筛选条件/) assert.match(aiMode, /查询业务单据接口/) assert.match(aiMode, /组合筛选单据/) assert.match(aiMode, /if \(await handleAiDocumentQueryIntent\(prompt, pendingMessage\)\) \{[\s\S]*return[\s\S]*\}/) assert.match(aiMode, /emit\('open-document', buildAiDocumentDetailRequest\(detailReference\)\)/) }) test('AI mode asks for manual confirmation before generating application preview table', () => { assert.match(aiMode, /function buildAiRequiredApplicationGateSuggestedActions\(flow, prompt = ''\)/) assert.match(aiMode, /label:\s*'确认发起出差申请'/) assert.match(aiMode, /action_type:\s*'ai_application_start_inline'/) assert.match(aiMode, /carry_text:\s*prompt/) assert.match(aiMode, /label:\s*'确认关联已有申请单'/) assert.match(aiMode, /flow_id:\s*'travel_reimbursement'/) assert.match(aiMode, /suggestedActions:\s*requiredApplicationContinuationFlow[\s\S]*buildAiRequiredApplicationGateSuggestedActions\(requiredApplicationContinuationFlow, prompt\)/) assert.doesNotMatch(aiMode, /continueAiRequiredApplicationGateFromPlan\(normalizedPlan, prompt\)/) assert.doesNotMatch(aiMode, /flow\.flowId === 'travel_application'[\s\S]*void startAiApplicationPreview\('travel', '差旅费', prompt\)/) assert.match(aiMode, /class="workbench-ai-application-preview application-preview-shell"/) assert.match(aiMode, /resolveInlineApplicationPreviewRows\(message\)/) assert.match(aiMode, /commitInlineApplicationPreviewEditor\(message\)/) }) test('AI mode shows pending feedback before async application preview estimate refresh', () => { const startPreviewFunction = aiMode.match( /async function startAiApplicationPreview[\s\S]*?\n}\n\nfunction requestDeleteCurrentConversation/ )?.[0] || '' assert.match(startPreviewFunction, /const pendingMessage = createInlineMessage\(\s*'assistant',\s*'正在生成申请核对表/) assert.ok( startPreviewFunction.indexOf('conversationMessages.value.push(pendingMessage)') < startPreviewFunction.indexOf('await refreshApplicationPreviewEstimate(') ) assert.match(startPreviewFunction, /pending:\s*true/) assert.match(startPreviewFunction, /replaceInlineMessage\(\s*pendingMessage\.id/) }) test('AI mode handles application preview save and submit through buttons or text commands', () => { assert.match(aiMode, /AI_APPLICATION_ACTION_SAVE_DRAFT/) assert.match(aiMode, /AI_APPLICATION_ACTION_SUBMIT/) assert.match(aiMode, /runAiApplicationPreviewAction/) assert.match(aiMode, /buildAiApplicationPrecheck/) assert.match(aiMode, /buildAiApplicationSubmitConflictMessage/) assert.match(aiMode, /isAiApplicationPrecheckBlocking/) assert.match(aiMode, /applicationSubmitConfirmOpen/) assert.match(aiMode, /确认直接提交申请/) assert.match(aiMode, /function buildInlineApplicationPreviewSuggestedActions\(applicationPreview = \{\}, draftPayload = null\)/) assert.match(aiMode, /label:\s*'直接提交'/) assert.match(aiMode, /function resolveInlineApplicationPreviewActionFromText\(text = ''\)/) assert.match(aiMode, /function executeInlineApplicationPreviewAction\(actionType, sourceMessage = null, options = \{\}\)/) assert.match(aiMode, /function confirmInlineApplicationSubmit\(\)/) assert.match(aiMode, /function cancelInlineApplicationSubmitConfirm\(\)/) assert.match(aiMode, /function handleInlineApplicationPreviewTextAction\(prompt\)/) assert.match(aiMode, /if \(handleInlineApplicationPreviewTextAction\(cleanPrompt\)\) \{[\s\S]*return[\s\S]*\}/) assert.match(aiMode, /\[AI_APPLICATION_ACTION_SAVE_DRAFT, AI_APPLICATION_ACTION_SUBMIT\]\.includes\(actionType\)/) assert.match(aiMode, /normalizedPreview\.readyToSubmit/) assert.match(aiMode, /fetchExpenseClaims\(\{ page: 1, pageSize: 100 \}\)/) assert.match(aiMode, /skipUserMessage/) assert.match(aiMode, /暂不能提交申请/) assert.match(aiMode, /#ai-open-application-detail:/) }) test('AI mode waits for submit confirmation before adding submit action to the conversation', () => { const executeStart = aiMode.indexOf('async function executeInlineApplicationPreviewAction') const executeEnd = aiMode.indexOf('\nfunction handleInlineApplicationPreviewTextAction', executeStart) const executeBlock = aiMode.slice(executeStart, executeEnd) const confirmGateIndex = executeBlock.indexOf('if (isSubmit && !options.confirmed)') const requestConfirmIndex = executeBlock.indexOf('requestInlineApplicationSubmitConfirmation', confirmGateIndex) const confirmedActionPushIndex = executeBlock.indexOf('pushInlineApplicationActionUserMessage(userText)', requestConfirmIndex) assert.ok(confirmGateIndex >= 0, '直接提交应先进入确认分支') assert.ok(requestConfirmIndex > confirmGateIndex, '直接提交确认分支应先打开确认弹窗') assert.ok(confirmedActionPushIndex > requestConfirmIndex, '确认弹窗打开前不应追加“直接提交”用户消息') assert.match( executeBlock, /requestInlineApplicationSubmitConfirmation\(targetMessage,\s*\{\s*\.\.\.options,\s*userText\s*\}\)/ ) const confirmStart = aiMode.indexOf('function confirmInlineApplicationSubmit()') const confirmEnd = aiMode.indexOf('\nasync function runInlineApplicationSubmitPrecheck', confirmStart) const confirmBlock = aiMode.slice(confirmStart, confirmEnd) assert.match(confirmBlock, /userText:\s*context\.userText \|\| '直接提交'/) assert.match(confirmBlock, /skipUserMessage:\s*false/) const cancelStart = aiMode.indexOf('function cancelInlineApplicationSubmitConfirm()') const cancelEnd = aiMode.indexOf('\nfunction confirmInlineApplicationSubmit', cancelStart) const cancelBlock = aiMode.slice(cancelStart, cancelEnd) assert.doesNotMatch(cancelBlock, /pushInlineUserMessage|pushInlineApplicationActionUserMessage/) }) test('AI mode formats saved application draft as a detail table without continuing submit flow', () => { assert.match(aiMode, /function buildInlineApplicationResultTable\(draftPayload = \{\}, options = \{\}\)/) assert.match(aiMode, /\| 单据类型 \| 单据编号 \| 单据状态 \| 当前节点 \| 操作 \|/) assert.match(aiMode, /\[查看\]\(\$\{href\}\)/) assert.match(aiMode, /buildInlineApplicationActionDetailHref\(info\)/) assert.match(aiMode, /params\.set\('claim_id', claimId\)/) assert.match(aiMode, /params\.set\('claim_no', claimNo\)/) const resultStart = aiMode.indexOf('function buildInlineApplicationPreviewActionResultText') const resultEnd = aiMode.indexOf('\nfunction buildInlineApplicationDetailAction', resultStart) const resultBlock = aiMode.slice(resultStart, resultEnd) const submitBranchIndex = resultBlock.indexOf('actionType === AI_APPLICATION_ACTION_SUBMIT') const saveBranchIndex = resultBlock.indexOf("'### 申请草稿已保存'") const saveBranch = resultBlock.slice(saveBranchIndex) assert.ok(submitBranchIndex >= 0) assert.ok(saveBranchIndex > submitBranchIndex, '保存草稿结果应走非提交分支') assert.match( saveBranch, /buildInlineApplicationResultTable\(draftPayload,\s*\{[\s\S]*statusLabel:\s*'草稿'[\s\S]*stageLabel:\s*'待提交'/ ) assert.doesNotMatch(saveBranch, /进入审批流程/) const executeStart = aiMode.indexOf('async function executeInlineApplicationPreviewAction') const executeEnd = aiMode.indexOf('\nfunction handleInlineApplicationPreviewTextAction', executeStart) const executeBlock = aiMode.slice(executeStart, executeEnd) assert.match(executeBlock, /targetMessage\.suggestedActions = \[\]/) assert.doesNotMatch( executeBlock, /targetMessage\.suggestedActions = isSubmit[\s\S]*buildInlineApplicationPreviewSuggestedActions\(targetMessage\.applicationPreview, draftPayload\)/ ) assert.match(executeBlock, /suggestedActions:\s*isSubmit\s*\?\s*buildInlineApplicationDetailAction\(draftPayload\)\s*:\s*\[\]/) }) test('AI mode locks application preview actions while estimate refresh is pending', () => { assert.match(aiMode, /function isApplicationPreviewEstimatePendingPreview\(applicationPreview = \{\}\)/) assert.match( aiMode, /function buildInlineApplicationPreviewSuggestedActions\(applicationPreview = \{\}, draftPayload = null\) \{[\s\S]*if \(isApplicationPreviewEstimatePendingPreview\(applicationPreview\)\) \{[\s\S]*return \[\]/ ) assert.match(aiMode, /const isAiModeInputLocked = computed\(\(\) => applicationPreviewEstimatePending\.value\)/) assert.match(aiMode, /:disabled="isAiModeInputLocked"/) assert.match(aiMode, /v-if="canShowInlineSuggestedActions\(message\)"/) assert.match(aiMode, /:disabled="isInlineSuggestedActionDisabled\(action, message\)"/) assert.match( aiMode, /message\.suggestedActions = \[\][\s\S]*const committed = await commitApplicationPreviewEditor\(message\)/ ) assert.match( aiMode, /if \(applicationPreviewEstimatePending\.value\) \{[\s\S]*toast\('请等待费用测算完成后再继续操作。'\)[\s\S]*return true/ ) assert.match( aiMode, /row\.editable && !isApplicationPreviewEstimatePending\(message\) \? 0 : -1/ ) assert.match( aiMode, /费用测算正在同步,请稍等,完成后才能保存草稿或直接提交。/ ) })