import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' import { fileURLToPath } from 'node:url' import { buildOperationFeedbackPayload, normalizeOperationFeedbackContext } from '../src/composables/useOperationFeedback.js' import { SESSION_TYPE_APPLICATION, SESSION_TYPE_EXPENSE, SESSION_TYPE_KNOWLEDGE, buildMessageMeta, buildWelcomeInsight, buildWelcomeMessage, createMessage, filterVisibleMessageMeta } from '../src/views/scripts/travelReimbursementConversationModel.js' const appShellRouteView = readFileSync( fileURLToPath(new URL('../src/views/AppShellRouteView.vue', import.meta.url)), 'utf8' ) const appShellComposable = readFileSync( fileURLToPath(new URL('../src/composables/useAppShell.js', import.meta.url)), 'utf8' ) const requestsComposable = readFileSync( fileURLToPath(new URL('../src/composables/useRequests.js', import.meta.url)), 'utf8' ) const assistantScript = readFileSync( fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)), 'utf8' ) const assistantCreateStateScript = readFileSync( fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementCreateViewState.js', import.meta.url)), 'utf8' ) const assistantCreateLifecycleScript = readFileSync( fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementCreateViewLifecycle.js', import.meta.url)), 'utf8' ) const assistantCreateControlsScript = readFileSync( fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementCreateViewControls.js', import.meta.url)), 'utf8' ) const assistantMessageActionsScript = readFileSync( fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementMessageActions.js', import.meta.url)), 'utf8' ) const assistantSubmitComposerScript = readFileSync( fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)), 'utf8' ) const assistantSubmitResponseModelScript = readFileSync( fileURLToPath(new URL('../src/views/scripts/travelReimbursementSubmitResponseModel.js', import.meta.url)), 'utf8' ) const assistantSessionStateScript = readFileSync( fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSessionState.js', import.meta.url)), 'utf8' ) const assistantSurface = [ assistantScript, assistantCreateStateScript, assistantCreateLifecycleScript, assistantCreateControlsScript, assistantMessageActionsScript, assistantSubmitComposerScript, assistantSubmitResponseModelScript, assistantSessionStateScript ].join('\n') const assistantTemplate = readFileSync( fileURLToPath(new URL('../src/views/TravelReimbursementCreateView.vue', import.meta.url)), 'utf8' ) const messageItemTemplate = readFileSync( fileURLToPath(new URL('../src/components/travel/TravelReimbursementMessageItem.vue', import.meta.url)), 'utf8' ) const chatViewTemplate = readFileSync( fileURLToPath(new URL('../src/views/ChatView.vue', import.meta.url)), 'utf8' ) test('application and reimbursement entries open the same financial assistant modal', () => { assert.match(appShellRouteView, / { assert.match(appShellRouteView, /:refresh-token="documentCenterRefreshToken"/) assert.match(appShellRouteView, /@reload="reloadRequests"/) assert.match(appShellComposable, /const documentCenterRefreshToken = ref\(0\)/) assert.match(appShellComposable, /async function reloadDocumentCenterRequests\(\) \{[\s\S]*documentCenterRefreshToken\.value \+= 1[\s\S]*return reloadRequests\(\)/) assert.match(appShellComposable, /if \(view === 'documents'\) \{[\s\S]*void reloadDocumentCenterRequests\(\)/) assert.match(appShellComposable, /shouldRefreshCurrentDocumentCenter[\s\S]*route\.name === 'app-documents'[\s\S]*void reloadDocumentCenterRequests\(\)/) assert.match(appShellComposable, /documentCenterRefreshToken,/) assert.match(appShellComposable, /reloadDocumentCenterRequests,/) }) test('documents center uses the full request list instead of the global date-filtered list', () => { assert.match( appShellRouteView, / 0"/ ) assert.doesNotMatch( appShellRouteView, / { assert.match(appShellComposable, /import \{ fetchAllApprovalExpenseClaims, fetchExpenseClaimDetail \} from '\.\.\/services\/reimbursements\.js'/) assert.match(appShellComposable, /const workbenchApprovalRequests = ref\(\[\]\)/) assert.match(appShellComposable, /async function reloadWorkbenchApprovalRequests\(\)/) assert.match(appShellComposable, /async function reloadWorkbenchRequests\(\)/) assert.match(appShellComposable, /fetchAllApprovalExpenseClaims\(\)/) assert.match(appShellComposable, /payload\.map\(\(item\) => mapExpenseClaimToRequest\(item\)\)/) assert.match(appShellComposable, /Promise\.all\(\[[\s\S]*reloadRequests\(\{ silent: true \}\),[\s\S]*reloadWorkbenchApprovalRequests\(\)[\s\S]*\]\)/) assert.match(appShellComposable, /if \(view === 'workbench'\) \{[\s\S]*void reloadWorkbenchRequests\(\)/) assert.match(appShellComposable, /const workbenchRequests = computed\(\(\) =>[\s\S]*mergeWorkbenchRequests\(requests\.value, workbenchApprovalRequests\.value\)/) assert.match(appShellComposable, /buildWorkbenchSummary\(workbenchRequests\.value, currentUser\.value\)/) assert.match(appShellRouteView, /)[\s\S])*workbenchRequests/) }) test('workbench progress refreshes after homepage create or detail updates', () => { assert.match(appShellComposable, /async function handleDraftSaved\(payload = \{\}\) \{[\s\S]*await reloadWorkbenchRequests\(\)/) assert.match(appShellComposable, /async function handleRequestUpdated\(payload = \{\}\) \{[\s\S]*await reloadWorkbenchRequests\(\)[\s\S]*await refreshSelectedRequestDetail\(claimId\)/) assert.doesNotMatch(appShellComposable, /async function handleRequestUpdated\(payload = \{\}\) \{[\s\S]*await reloadRequests\(\)[\s\S]*await refreshSelectedRequestDetail/) }) test('workbench progress refresh is silent to avoid homepage flashing', () => { assert.match(requestsComposable, /async function reload\(options = \{\}\) \{[\s\S]*const silent = Boolean\(options\?\.silent\)/) assert.match(requestsComposable, /if \(!silent\) \{[\s\S]*loading\.value = true[\s\S]*error\.value = ''[\s\S]*\}/) assert.match(requestsComposable, /catch \(nextError\) \{[\s\S]*if \(!silent\) \{[\s\S]*requests\.value = \[\][\s\S]*\}/) assert.match(requestsComposable, /finally \{[\s\S]*if \(!silent\) \{[\s\S]*loading\.value = false[\s\S]*\}/) assert.match(appShellComposable, /async function reloadWorkbenchRequests\(\) \{[\s\S]*reloadRequests\(\{ silent: true \}\)/) assert.match(appShellComposable, /async function reloadDocumentCenterRequests\(\) \{[\s\S]*return reloadRequests\(\)/) }) test('document detail navigation preserves document center list query', () => { assert.match( appShellComposable, /function openRequestDetail\(request, options = \{\}\) \{[\s\S]*const requestId = resolveRequestDetailLookupId\(request\)[\s\S]*name: 'app-document-detail'[\s\S]*params: \{ requestId \},[\s\S]*query: buildDocumentDetailQuery\(options\)/ ) assert.match( appShellComposable, /function closeRequestDetail\(\) \{[\s\S]*router\.push\(\{ name: 'app-documents', query: buildDocumentReturnQuery\(\) \}\)/ ) assert.match( appShellComposable, /async function handleRequestDeleted\(payload = \{\}\) \{[\s\S]*router\.push\(\{ name: 'app-documents', query: buildDocumentReturnQuery\(\) \}\)/ ) }) test('document detail refreshes claim detail instead of relying on stale list cache', () => { assert.match(appShellComposable, /import \{ fetchAllApprovalExpenseClaims, fetchExpenseClaimDetail \} from '\.\.\/services\/reimbursements\.js'/) assert.match(appShellComposable, /import \{ mapExpenseClaimToRequest, useRequests \} from '\.\/useRequests\.js'/) assert.match(appShellComposable, /const snapshot = normalizeRequestForUi\(selectedRequestSnapshot\.value\)[\s\S]*if \(isSameRequestIdentity\(snapshot, requestId\)\) \{[\s\S]*return snapshot/) assert.match(appShellComposable, /async function refreshSelectedRequestDetail\(requestOrId = selectedRequestSnapshot\.value\) \{[\s\S]*fetchExpenseClaimDetail\(lookupId\)[\s\S]*mapExpenseClaimToRequest\(payload\)[\s\S]*upsertRequestSnapshot\(mappedRequest\)/) assert.match(appShellComposable, /function isDetailLookupOnlyPayload\(payload = \{\}\) \{[\s\S]*payload\?\.detailLookupOnly/) assert.match(appShellComposable, /function openRequestDetail\(request, options = \{\}\) \{[\s\S]*selectedRequestSnapshot\.value = isDetailLookupOnlyRequest \? null : request \|\| null[\s\S]*void refreshSelectedRequestDetail\(isDetailLookupOnlyRequest \? requestId : request\)/) assert.match(appShellComposable, /async function handleRequestUpdated\(payload = \{\}\) \{[\s\S]*await reloadWorkbenchRequests\(\)[\s\S]*await refreshSelectedRequestDetail\(claimId\)/) assert.match(appShellComposable, /route\.name === 'app-document-detail'[\s\S]*void refreshSelectedRequestDetail\(String\(route\.params\.requestId \|\| ''\)\)/) }) test('application entry keeps its own assistant source without creating a separate dialog', () => { assert.match(appShellComposable, /const SMART_ENTRY_SOURCE_APPLICATION = 'application'/) assert.match(appShellComposable, /function openExpenseApplicationCreate\(\) \{[\s\S]*openFinancialAssistantCreate\(SMART_ENTRY_SOURCE_APPLICATION\)/) assert.match(appShellComposable, /function openTravelCreate\(\) \{[\s\S]*openFinancialAssistantCreate\(SMART_ENTRY_SOURCE_REIMBURSEMENT\)/) assert.match(appShellComposable, /openExpenseApplicationCreate,/) assert.match(assistantSurface, /activeSessionType\.value === SESSION_TYPE_APPLICATION[\s\S]*我想先申请一笔差旅费用/) }) test('application edit prefill opens assistant without auto submit', () => { assert.match(appShellRouteView, /:initial-prompt-auto-submit="smartEntryContext\.initialPromptAutoSubmit"/) assert.match(appShellRouteView, /:initial-application-preview="smartEntryContext\.initialApplicationPreview"/) assert.match(appShellRouteView, /:initial-draft-payload="smartEntryContext\.initialDraftPayload"/) assert.match(appShellComposable, /initialPromptAutoSubmit:\s*true/) assert.match(appShellComposable, /initialApplicationPreview:\s*null/) assert.match(appShellComposable, /initialDraftPayload:\s*null/) assert.match(appShellComposable, /initialPromptAutoSubmit:\s*payload\.initialPromptAutoSubmit !== false/) assert.match(appShellComposable, /initialApplicationPreview:\s*payload\.applicationPreview && typeof payload\.applicationPreview === 'object'/) assert.match(appShellComposable, /initialDraftPayload:\s*payload\.draftPayload && typeof payload\.draftPayload === 'object'/) assert.match( assistantScript, /initialPromptAutoSubmit:\s*\{[\s\S]*type:\s*Boolean[\s\S]*default:\s*true/ ) assert.match( assistantScript, /initialApplicationPreview:\s*\{[\s\S]*type:\s*Object[\s\S]*default:\s*null/ ) assert.match( assistantScript, /initialDraftPayload:\s*\{[\s\S]*type:\s*Object[\s\S]*default:\s*null/ ) assert.match( assistantSurface, /props\.initialApplicationPreview[\s\S]*normalizeApplicationPreview\(props\.initialApplicationPreview\)[\s\S]*const draftPayload = props\.initialDraftPayload[\s\S]*createMessage\('assistant', buildLocalApplicationPreviewMessage\(applicationPreview\)[\s\S]*draftPayload/ ) assert.match(assistantSessionStateScript, /&& !props\.initialApplicationPreview/) assert.match( assistantSurface, /if \(props\.initialPromptAutoSubmit !== false\) \{[\s\S]*submitComposer\(\)/ ) }) test('financial assistant toolbar renders isolated assistant sessions without steward entry', () => { assert.match(assistantSurface, /filterAssistantSessionModes\(ASSISTANT_SESSION_MODE_OPTIONS, currentUser\.value\)/) assert.match(assistantSurface, /\.filter\(\(mode\) => mode\.key !== SESSION_TYPE_STEWARD\)/) assert.match(assistantSurface, /mode\.key === SESSION_TYPE_BUDGET/) assert.match(assistantSurface, /visibleModes\.map/) assert.match(assistantSurface, /targetSessionType:\s*mode\.key/) assert.match(assistantSurface, /active:\s*mode\.key === activeSessionType\.value/) assert.match(assistantTemplate, /:class="\{ active: shortcut\.active \}"/) assert.match(assistantTemplate, /:aria-pressed="shortcut\.active \? 'true' : 'false'"/) assert.match(assistantTemplate, /:disabled="shortcut\.active \|\| submitting/) }) test('closing a busy assistant keeps the running instance recoverable', () => { assert.match(appShellRouteView, /:reopen-token="smartEntryRevealToken"/) assert.match(appShellComposable, /const smartEntryRevealToken = ref\(0\)/) assert.match(appShellComposable, /if \(smartEntryOpen\.value\) \{\s*smartEntryRevealToken\.value \+= 1\s*return\s*\}/) assert.match(appShellComposable, /smartEntryRevealToken,/) assert.match(assistantScript, /reopenToken:\s*\{\s*type:\s*Number/) assert.match(assistantSurface, /closeAfterBusy\.value = false[\s\S]*workbenchVisible\.value = true/) assert.match(assistantSurface, /function emitCloseAfterLeave\(\) \{\s*if \(workbenchVisible\.value\)/) }) test('financial assistant welcome copy differentiates application intent from reimbursement entry', () => { const user = { name: '李文静', username: 'wenjing.li', grade: 'P5' } const applicationWelcome = buildWelcomeMessage('application', null, SESSION_TYPE_APPLICATION, user) const reimbursementWelcome = buildWelcomeMessage('topbar', null, SESSION_TYPE_EXPENSE, user) const knowledgeWelcome = buildWelcomeMessage('topbar', null, SESSION_TYPE_KNOWLEDGE, user) const applicationInsight = buildWelcomeInsight('application', null, SESSION_TYPE_APPLICATION, user) assert.match(applicationWelcome, /申请助手/) assert.match(applicationWelcome, /费用申请、报销申请还是其他财务事项/) assert.match(reimbursementWelcome, /报销助手/) assert.match(reimbursementWelcome, /报销发起、票据识别、草稿归集、报销信息核对/) assert.match(knowledgeWelcome, /财务知识助手/) assert.notEqual(applicationWelcome, reimbursementWelcome) assert.equal(applicationInsight.metricValue, '申请助手') assert.equal(applicationInsight.title, '申请助手') }) test('assistant message meta hides internal routing and permission chips', () => { const meta = buildMessageMeta( { selected_agent: 'user_agent', permission_level: 'draft_write', run_id: 'run-001', trace_summary: { tool_count: 3, degraded: true }, requires_confirmation: true }, ['invoice.pdf'] ) assert.deepEqual(meta, ['已降级', '待确认', '附件: 1']) assert.deepEqual( filterVisibleMessageMeta(['Agent: user_agent', '权限: draft_write', 'Run: run-001', '工具: 3', '等待确认']), ['等待确认'] ) assert.deepEqual( createMessage('assistant', '测试', [], { meta: ['Agent: user_agent', '权限: draft_write', '处理中'] }).meta, ['处理中'] ) assert.doesNotMatch(messageItemTemplate, /message-meta-row|message-meta-chip/) assert.doesNotMatch(chatViewTemplate, /agent-meta-row|agent-meta-chip/) }) test('assistant message action toolbar collects lightweight feedback', () => { assert.doesNotMatch(appShellRouteView, /