2026-05-25 13:35:39 +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 {
|
|
|
|
|
buildOperationFeedbackPayload,
|
|
|
|
|
normalizeOperationFeedbackContext
|
|
|
|
|
} from '../src/composables/useOperationFeedback.js'
|
|
|
|
|
|
2026-05-25 13:35:39 +08:00
|
|
|
import {
|
|
|
|
|
SESSION_TYPE_APPLICATION,
|
|
|
|
|
SESSION_TYPE_EXPENSE,
|
|
|
|
|
SESSION_TYPE_KNOWLEDGE,
|
2026-05-30 15:46:51 +08:00
|
|
|
buildMessageMeta,
|
2026-05-25 13:35:39 +08:00
|
|
|
buildWelcomeInsight,
|
2026-05-30 15:46:51 +08:00
|
|
|
buildWelcomeMessage,
|
|
|
|
|
createMessage,
|
|
|
|
|
filterVisibleMessageMeta
|
2026-05-25 13:35:39 +08:00
|
|
|
} 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 assistantScript = readFileSync(
|
|
|
|
|
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
|
|
|
|
|
'utf8'
|
|
|
|
|
)
|
2026-05-30 15:46:51 +08:00
|
|
|
const assistantSubmitComposerScript = readFileSync(
|
|
|
|
|
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
|
|
|
|
|
'utf8'
|
|
|
|
|
)
|
2026-06-03 09:25:23 +08:00
|
|
|
const assistantSessionStateScript = readFileSync(
|
|
|
|
|
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSessionState.js', import.meta.url)),
|
|
|
|
|
'utf8'
|
|
|
|
|
)
|
2026-05-25 13:35:39 +08:00
|
|
|
const assistantTemplate = readFileSync(
|
|
|
|
|
fileURLToPath(new URL('../src/views/TravelReimbursementCreateView.vue', import.meta.url)),
|
|
|
|
|
'utf8'
|
|
|
|
|
)
|
2026-05-30 15:46:51 +08:00
|
|
|
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'
|
|
|
|
|
)
|
|
|
|
|
const operationFeedbackInlineTemplate = readFileSync(
|
|
|
|
|
fileURLToPath(new URL('../src/components/shared/OperationFeedbackInlineCard.vue', import.meta.url)),
|
|
|
|
|
'utf8'
|
|
|
|
|
)
|
2026-05-25 13:35:39 +08:00
|
|
|
|
|
|
|
|
test('application and reimbursement entries open the same financial assistant modal', () => {
|
|
|
|
|
assert.match(appShellRouteView, /<TravelReimbursementCreateView[\s\S]*:entry-source="smartEntryContext\.source"/)
|
|
|
|
|
assert.match(appShellRouteView, /@create-request="openTravelCreate"/)
|
|
|
|
|
assert.match(appShellRouteView, /@create-application="openExpenseApplicationCreate"/)
|
|
|
|
|
assert.match(appShellRouteView, /@new-application="openExpenseApplicationCreate"/)
|
|
|
|
|
assert.doesNotMatch(appShellRouteView, /ExpenseApplicationDialog/)
|
|
|
|
|
})
|
|
|
|
|
|
2026-06-03 09:25:23 +08:00
|
|
|
test('documents center reloads immediately when entered or clicked again', () => {
|
|
|
|
|
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,/)
|
|
|
|
|
})
|
|
|
|
|
|
2026-06-03 15:46:56 +08:00
|
|
|
test('document detail navigation preserves document center list query', () => {
|
|
|
|
|
assert.match(
|
|
|
|
|
appShellComposable,
|
2026-06-03 22:15:45 +08:00
|
|
|
/function openRequestDetail\(request, options = \{\}\) \{[\s\S]*name: 'app-document-detail'[\s\S]*params: \{ requestId: request\.claimId \|\| request\.id \},[\s\S]*query: buildDocumentDetailQuery\(options\)/
|
2026-06-03 15:46:56 +08:00
|
|
|
)
|
|
|
|
|
assert.match(
|
|
|
|
|
appShellComposable,
|
2026-06-03 22:15:45 +08:00
|
|
|
/function closeRequestDetail\(\) \{[\s\S]*router\.push\(\{ name: 'app-documents', query: buildDocumentReturnQuery\(\) \}\)/
|
2026-06-03 15:46:56 +08:00
|
|
|
)
|
|
|
|
|
assert.match(
|
|
|
|
|
appShellComposable,
|
2026-06-03 22:15:45 +08:00
|
|
|
/async function handleRequestDeleted\(payload = \{\}\) \{[\s\S]*router\.push\(\{ name: 'app-documents', query: buildDocumentReturnQuery\(\) \}\)/
|
2026-06-03 15:46:56 +08:00
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
2026-06-03 22:15:45 +08:00
|
|
|
test('document detail refreshes claim detail instead of relying on stale list cache', () => {
|
|
|
|
|
assert.match(appShellComposable, /import \{ 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 openRequestDetail\(request, options = \{\}\) \{[\s\S]*void refreshSelectedRequestDetail\(request\)/)
|
|
|
|
|
assert.match(appShellComposable, /async function handleRequestUpdated\(\) \{[\s\S]*await reloadRequests\(\)[\s\S]*await refreshSelectedRequestDetail\(String\(route\.params\.requestId \|\| ''\)\)/)
|
|
|
|
|
assert.match(appShellComposable, /route\.name === 'app-document-detail'[\s\S]*void refreshSelectedRequestDetail\(String\(route\.params\.requestId \|\| ''\)\)/)
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-25 13:35:39 +08:00
|
|
|
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(assistantScript, /activeSessionType\.value === SESSION_TYPE_APPLICATION[\s\S]*我想先申请一笔差旅费用/)
|
|
|
|
|
})
|
|
|
|
|
|
2026-06-03 09:25:23 +08:00
|
|
|
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(appShellComposable, /initialPromptAutoSubmit:\s*true/)
|
|
|
|
|
assert.match(appShellComposable, /initialApplicationPreview:\s*null/)
|
|
|
|
|
assert.match(appShellComposable, /initialPromptAutoSubmit:\s*payload\.initialPromptAutoSubmit !== false/)
|
|
|
|
|
assert.match(appShellComposable, /initialApplicationPreview:\s*payload\.applicationPreview && typeof payload\.applicationPreview === '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,
|
|
|
|
|
/props\.initialApplicationPreview[\s\S]*normalizeApplicationPreview\(props\.initialApplicationPreview\)[\s\S]*createMessage\('assistant', buildLocalApplicationPreviewMessage\(applicationPreview\)/
|
|
|
|
|
)
|
|
|
|
|
assert.match(assistantSessionStateScript, /&& !props\.initialApplicationPreview/)
|
|
|
|
|
assert.match(
|
|
|
|
|
assistantScript,
|
|
|
|
|
/if \(props\.initialPromptAutoSubmit !== false\) \{[\s\S]*submitComposer\(\)/
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-25 13:35:39 +08:00
|
|
|
test('financial assistant toolbar renders four isolated assistant sessions', () => {
|
2026-05-30 15:46:51 +08:00
|
|
|
assert.match(assistantScript, /filterAssistantSessionModes\(ASSISTANT_SESSION_MODE_OPTIONS, currentUser\.value\)/)
|
|
|
|
|
assert.match(assistantScript, /visibleModes\.map/)
|
2026-05-25 13:35:39 +08:00
|
|
|
assert.match(assistantScript, /targetSessionType:\s*mode\.key/)
|
|
|
|
|
assert.match(assistantScript, /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/)
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-26 09:15:14 +08:00
|
|
|
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(assistantScript, /closeAfterBusy\.value = false[\s\S]*workbenchVisible\.value = true/)
|
|
|
|
|
assert.match(assistantScript, /function emitCloseAfterLeave\(\) \{\s*if \(workbenchVisible\.value\)/)
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-25 13:35:39 +08:00
|
|
|
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, '申请助手')
|
|
|
|
|
})
|
2026-05-30 15:46:51 +08:00
|
|
|
|
|
|
|
|
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 operation feedback is inline and persists run context', () => {
|
|
|
|
|
assert.doesNotMatch(appShellRouteView, /<OperationFeedbackDialog/)
|
|
|
|
|
assert.doesNotMatch(appShellRouteView, /@operation-completed="handleOperationCompleted"/)
|
|
|
|
|
assert.doesNotMatch(appShellComposable, /useOperationFeedback/)
|
|
|
|
|
assert.match(messageItemTemplate, /<OperationFeedbackInlineCard/)
|
|
|
|
|
assert.match(messageItemTemplate, /class="message-feedback-bubble"/)
|
|
|
|
|
assert.match(messageItemTemplate, /:submitted="Boolean\(message\.operationFeedback\?\.submitted\)"/)
|
|
|
|
|
assert.match(messageItemTemplate, /:submitted-rating="Number\(message\.operationFeedback\?\.rating \|\| 0\)"/)
|
|
|
|
|
assert.match(assistantScript, /emits:\s*\['close', 'draft-saved', 'request-updated'\]/)
|
|
|
|
|
assert.match(appShellRouteView, /@request-updated="handleRequestUpdated"/)
|
|
|
|
|
assert.match(assistantScript, /function submitOperationFeedbackForMessage/)
|
|
|
|
|
assert.match(assistantScript, /createOperationFeedback/)
|
|
|
|
|
assert.match(assistantScript, /normalizeOperationFeedbackContext/)
|
|
|
|
|
assert.match(assistantScript, /&& !feedback\.dismissed/)
|
|
|
|
|
assert.doesNotMatch(assistantScript, /&& !feedback\.submitted/)
|
|
|
|
|
assert.match(assistantScript, /submitted:\s*true/)
|
|
|
|
|
assert.match(assistantScript, /dismissed:\s*false/)
|
|
|
|
|
assert.doesNotMatch(assistantScript, /emit\('operation-completed'/)
|
|
|
|
|
assert.match(assistantSubmitComposerScript, /emitOperationCompleted\?\.\(payload/)
|
|
|
|
|
assert.match(assistantSubmitComposerScript, /operationFeedback:\s*buildOperationFeedbackState/)
|
|
|
|
|
assert.match(assistantSubmitComposerScript, /rating:\s*0/)
|
|
|
|
|
assert.match(operationFeedbackInlineTemplate, /v-for="option in ratingOptions"/)
|
|
|
|
|
assert.match(operationFeedbackInlineTemplate, /is-submitted/)
|
|
|
|
|
assert.match(operationFeedbackInlineTemplate, /submittedRating/)
|
|
|
|
|
assert.match(operationFeedbackInlineTemplate, /感谢您的反馈。谢谢/)
|
|
|
|
|
assert.match(operationFeedbackInlineTemplate, /busy \|\| submitted/)
|
|
|
|
|
assert.match(operationFeedbackInlineTemplate, /role="radiogroup"/)
|
|
|
|
|
assert.match(operationFeedbackInlineTemplate, /handleRatingKeydown/)
|
|
|
|
|
assert.match(operationFeedbackInlineTemplate, /operation-feedback-stars/)
|
|
|
|
|
assert.match(operationFeedbackInlineTemplate, /score > 3/)
|
|
|
|
|
assert.match(operationFeedbackInlineTemplate, /v-if="showReasonInput"/)
|
|
|
|
|
assert.match(operationFeedbackInlineTemplate, /稍后/)
|
|
|
|
|
|
|
|
|
|
const context = normalizeOperationFeedbackContext(
|
|
|
|
|
{
|
|
|
|
|
run_id: 'run-001',
|
|
|
|
|
conversation_id: 'conv-001',
|
|
|
|
|
selected_agent: 'user_agent',
|
|
|
|
|
session_type: 'application',
|
|
|
|
|
operation_status: 'succeeded',
|
|
|
|
|
route_reason: 'model_route',
|
|
|
|
|
result: { answer: '处理完成' }
|
|
|
|
|
},
|
|
|
|
|
{ username: 'wenjing.li' }
|
|
|
|
|
)
|
|
|
|
|
const payload = buildOperationFeedbackPayload(context, { rating: 2, reason: '识别错了' })
|
|
|
|
|
|
|
|
|
|
assert.equal(context.runId, 'run-001')
|
|
|
|
|
assert.equal(context.userId, 'wenjing.li')
|
|
|
|
|
assert.equal(payload.run_id, 'run-001')
|
|
|
|
|
assert.equal(payload.conversation_id, 'conv-001')
|
|
|
|
|
assert.equal(payload.agent, 'user_agent')
|
|
|
|
|
assert.equal(payload.rating, 2)
|
|
|
|
|
assert.equal(payload.reason, '识别错了')
|
|
|
|
|
assert.equal(payload.context_json.low_rating, true)
|
|
|
|
|
})
|