refactor: enforce 800 line source limits

This commit is contained in:
caoxiaozhu
2026-06-22 11:58:53 +08:00
parent 08a4fa3577
commit 6d33ba5742
150 changed files with 27413 additions and 23791 deletions

View File

@@ -0,0 +1,281 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
buildAiAttachmentAssociationActions,
buildAiAttachmentAssociationMessage,
buildAiAttachmentAssociationResultMessage,
resolveAiAttachmentAssociationMatch
} from '../src/utils/aiAttachmentAssociationModel.js'
import { renderAiConversationHtml } from '../src/utils/aiConversationHtmlRenderer.js'
function findAction(actions, actionType) {
return actions.find((action) => action.action_type === actionType)
}
test('根据票据日期和行程自动匹配最可能关联的报销单', () => {
const claims = [
{
id: 'claim-wuhan-shanghai',
claim_no: 'BX-20260220-001',
expense_type: 'travel',
status: 'draft',
reason: '辅助国网仿生产服务器部署,武汉往返上海',
location: '上海',
occurred_at: '2026-02-20'
},
{
id: 'claim-nanjing',
claim_no: 'BX-20260301-002',
expense_type: 'travel',
status: 'draft',
reason: '南京客户拜访',
location: '南京',
occurred_at: '2026-03-01'
}
]
const ocrDocuments = [
{
filename: '2月20 武汉-上海.pdf',
summary: '武汉至上海高铁票',
document_fields: [
{ key: 'date', label: '日期', value: '2026-02-20' },
{ key: 'route', label: '行程', value: '武汉-上海' }
]
},
{
filename: '2月23 上海-武汉.pdf',
summary: '上海至武汉高铁票',
document_fields: [
{ key: 'date', label: '日期', value: '2026-02-23' },
{ key: 'route', label: '行程', value: '上海-武汉' }
]
}
]
const match = resolveAiAttachmentAssociationMatch(claims, ocrDocuments)
assert.equal(match.highConfidence, true)
assert.equal(match.best.record.claimId, 'claim-wuhan-shanghai')
assert.match(match.best.reasons.join('、'), /日期/)
assert.match(match.best.reasons.join('、'), /地点|行程/)
const message = buildAiAttachmentAssociationMessage({
match,
fileNames: ocrDocuments.map((item) => item.filename),
ocrDocuments
})
assert.match(message, /BX-20260220-001/)
assert.match(message, /确认是否自动归集/)
assert.match(message, /ai-trusted-html:start/)
assert.ok(message.indexOf('票据识别结果') < message.indexOf('可能关联单据'))
const html = renderAiConversationHtml(message)
assert.match(html, /ai-ocr-recognition-card/)
assert.match(html, /ai-attachment-association-card/)
assert.match(html, /可能关联单据/)
assert.doesNotMatch(html, /26429165800002785705/)
const actions = buildAiAttachmentAssociationActions(match, 'assoc-1', { includeOcrDetails: true })
assert.equal(actions[0].action_type, 'show_ai_attachment_ocr_details')
assert.equal(findAction(actions, 'confirm_ai_attachment_association').payload.association_id, 'assoc-1')
assert.ok(findAction(actions, 'open_application_detail'))
})
test('自动归集消息展示票面 OCR 关键字段', () => {
const match = resolveAiAttachmentAssociationMatch([
{
id: 'claim-wuhan-shanghai',
claim_no: 'BX-20260220-001',
expense_type: 'travel',
status: 'draft',
reason: '辅助国网仿生产服务器部署,武汉往返上海',
location: '上海',
occurred_at: '2026-02-20'
}
], [
{
filename: '2月20 武汉-上海.pdf',
summary: '电子发票(铁路电子客票) 武汉-上海 票价 354元',
document_fields: [
{ key: 'amount', label: '金额', value: '354元' },
{ key: 'departed_at', label: '列车出发时间', value: '2026-02-20 07:55' },
{ key: 'route', label: '行程', value: '武汉-上海' }
]
}
])
const message = buildAiAttachmentAssociationMessage({
match,
fileNames: ['2月20 武汉-上海.pdf'],
ocrDocuments: [
{
filename: '2月20 武汉-上海.pdf',
summary: '电子发票(铁路电子客票) 武汉-上海 票价 354元',
document_fields: [
{ key: 'amount', label: '金额', value: '354元' },
{ key: 'departed_at', label: '列车出发时间', value: '2026-02-20 07:55' },
{ key: 'route', label: '行程', value: '武汉-上海' }
]
}
]
})
const html = renderAiConversationHtml(message)
assert.ok(message.indexOf('票据识别结果') < message.indexOf('可能关联单据'))
assert.match(message, /票面识别/)
assert.match(html, /ai-ocr-recognition-card/)
assert.match(html, /金额354元/)
assert.match(html, /列车出发时间2026-02-20 07:55/)
assert.match(html, /行程:武汉-上海/)
})
test('自动归集卡片不把票据数字残片当成单据事项', () => {
const dirtyReason = ':26429165800002785705; :2026; 05'
const ocrDocuments = [
{
filename: '2月20 武汉-上海.pdf',
summary: '电子发票(铁路电子客票) 武汉-上海 票价 354元',
document_fields: [
{ key: 'invoice_number', label: '', value: '26429165800002785705' },
{ key: 'year', label: '', value: '2026' },
{ key: 'amount', label: '金额', value: '354元' },
{ key: 'route', label: '行程', value: '武汉-上海' }
]
}
]
const match = resolveAiAttachmentAssociationMatch([
{
id: 'claim-dirty-reason',
claim_no: 'R74CB7C2R',
expense_type: 'travel',
status: 'draft',
reason: dirtyReason,
location: '上海',
occurred_at: '2026-02-20'
}
], ocrDocuments)
const message = buildAiAttachmentAssociationMessage({
match,
fileNames: ['2月20 武汉-上海.pdf'],
ocrDocuments
})
const html = renderAiConversationHtml(message)
assert.match(html, /R74CB7C2R/)
assert.match(html, /ai-ocr-recognition-card/)
assert.match(html, /票面识别/)
assert.match(html, /金额354元/)
assert.match(html, /行程:武汉-上海/)
assert.doesNotMatch(html, /单据事项/)
assert.doesNotMatch(html, /26429165800002785705/)
assert.doesNotMatch(html, /:2026/)
})
test('没有可关联草稿时给出清晰提示', () => {
const match = resolveAiAttachmentAssociationMatch([
{
id: 'claim-submitted',
claim_no: 'BX-20260220-001',
status: 'submitted',
reason: '已提交报销单',
location: '上海',
occurred_at: '2026-02-20'
}
], [
{
filename: '2月20 武汉-上海.pdf',
summary: '武汉至上海高铁票',
document_fields: [
{ key: 'date', label: '日期', value: '2026-02-20' },
{ key: 'route', label: '行程', value: '武汉-上海' }
]
}
])
assert.equal(match.highConfidence, false)
assert.equal(match.rankedRecords.length, 0)
const message = buildAiAttachmentAssociationMessage({ match, fileNames: ['2月20 武汉-上海.pdf'] })
assert.match(message, /没有查到可关联的报销草稿/)
})
test('自动归集结果用卡片告知上传结果', () => {
const message = buildAiAttachmentAssociationResultMessage({
claimNo: 'BX-20260220-001',
uploadedCount: 2,
skippedCount: 0,
fileNames: ['2月20 武汉-上海.pdf', '2月23 上海-武汉.pdf']
})
const html = renderAiConversationHtml(message)
assert.match(message, /已完成自动归集/)
assert.match(html, /ai-attachment-association-card/)
assert.match(html, /2 份/)
assert.match(html, /BX-20260220-001/)
})
test('低置信候选也提供快捷确认关联动作', () => {
const claims = [
{
id: 'claim-shanghai',
claim_no: 'R74CB7C2R',
expense_type: 'travel',
status: 'draft',
reason: '出差报销',
location: '上海',
occurred_at: '2026-05-18'
}
]
const ocrDocuments = [
{
filename: '2月20 武汉-上海.pdf',
summary: 'G458 Wuhan Shanghaihongqiao',
document_fields: [
{ key: 'route', label: '行程', value: 'Wuhan Shanghaihongqiao' }
]
}
]
const match = resolveAiAttachmentAssociationMatch(claims, ocrDocuments)
assert.equal(match.highConfidence, false)
assert.equal(match.recommended.record.claimId, 'claim-shanghai')
const message = buildAiAttachmentAssociationMessage({
match,
fileNames: ['2月20 武汉-上海.pdf'],
ocrDocuments
})
const html = renderAiConversationHtml(message)
assert.match(html, /候选单据待核对/)
assert.match(html, /ai-attachment-association-card/)
const actions = buildAiAttachmentAssociationActions(match, 'assoc-low-confidence', { includeOcrDetails: true })
assert.equal(actions[0].action_type, 'show_ai_attachment_ocr_details')
assert.equal(findAction(actions, 'confirm_ai_attachment_association').payload.claim_no, 'R74CB7C2R')
})
test('旧版纯文本关联消息也渲染为卡片', () => {
const legacyMessage = [
'### 我已先识别票据,并匹配到最可能的报销单',
'',
'本次附件1 份2月20 武汉-上海.pdf',
'',
'识别摘要2月20 武汉-上海.pdfG458 Wuhan Shanghaihongqiao',
'',
'推荐关联R74CB7C2R',
'',
'单据事项:上海差旅报销',
'',
'匹配依据:地点或行程包含 上海;当前单据仍是可归集草稿',
'',
'你可以直接点下方“查看匹配单据”核对详情,不需要再手动查找。'
].join('\n')
const html = renderAiConversationHtml(legacyMessage)
assert.match(html, /ai-attachment-association-card/)
assert.match(html, /R74CB7C2R/)
assert.doesNotMatch(html, /\*\*R74CB7C2R\*\*/)
assert.doesNotMatch(html, /ai-html-title/)
})

View File

@@ -146,3 +146,26 @@ test('AI conversation renderer keeps separated step bullets in one numbered sequ
assert.match(rendered, /<span class="ai-html-step-index">2<\/span>[\s\S]*预算与审批预审/)
assert.match(rendered, /<span class="ai-html-step-index">3<\/span>[\s\S]*申请表生成/)
})
test('AI conversation renderer hides noisy attachment association reason fragments', () => {
const rendered = renderAiConversationHtml([
'### 我已先识别票据,并匹配到最可能的报销单',
'',
'本次附件1 份2月20 武汉-上海.pdf',
'',
'识别摘要2月20 武汉-上海.pdf电子发票铁路电子客票 武汉-上海 票价 354元',
'',
'推荐关联R74CB7C2R',
'',
'单据事项::26429165800002785705; :2026; 05',
'',
'匹配依据:票据日期与报销单日期一致;地点或行程包含 上海;当前单据仍是可归集草稿'
].join('\n'))
assert.match(rendered, /ai-attachment-association-card/)
assert.match(rendered, /R74CB7C2R/)
assert.doesNotMatch(rendered, /单据事项/)
assert.doesNotMatch(rendered, /关联事项/)
assert.doesNotMatch(rendered, /26429165800002785705/)
assert.doesNotMatch(rendered, /:2026/)
})

View File

@@ -21,6 +21,8 @@ test('AI detail request keeps business application number out of claimId for leg
assert.equal(request.documentNo, 'AP-202606200001-ABCDEFGH')
assert.equal(request.documentTypeCode, 'application')
assert.equal(request.detailLookupOnly, true)
assert.equal(request.source, 'ai-conversation')
assert.equal(request.returnTo, 'conversation')
})
test('AI detail request uses explicit claim_id as lookup identity', () => {
@@ -39,6 +41,7 @@ test('AI detail request uses explicit claim_id as lookup identity', () => {
assert.equal(request.claimNo, 'AP-APPROVAL-001')
assert.equal(request.documentNo, 'AP-APPROVAL-001')
assert.equal(request.documentTypeCode, 'application')
assert.equal(request.returnTo, 'conversation')
})
test('AI detail request treats non-number references as internal claim ids', () => {
@@ -48,4 +51,5 @@ test('AI detail request treats non-number references as internal claim ids', ()
assert.equal(request.claimId, 'approval-internal-id')
assert.equal(request.claimNo, '')
assert.equal(request.documentNo, 'approval-internal-id')
assert.equal(request.returnTo, 'conversation')
})

View File

@@ -35,14 +35,44 @@ 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'
@@ -146,7 +176,7 @@ test('application entry keeps its own assistant source without creating a separa
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]*我想先申请一笔差旅费用/)
assert.match(assistantSurface, /activeSessionType\.value === SESSION_TYPE_APPLICATION[\s\S]*我想先申请一笔差旅费用/)
})
test('application edit prefill opens assistant without auto submit', () => {
@@ -165,23 +195,23 @@ test('application edit prefill opens assistant without auto submit', () => {
/initialApplicationPreview:\s*\{[\s\S]*type:\s*Object[\s\S]*default:\s*null/
)
assert.match(
assistantScript,
assistantSurface,
/props\.initialApplicationPreview[\s\S]*normalizeApplicationPreview\(props\.initialApplicationPreview\)[\s\S]*createMessage\('assistant', buildLocalApplicationPreviewMessage\(applicationPreview\)/
)
assert.match(assistantSessionStateScript, /&& !props\.initialApplicationPreview/)
assert.match(
assistantScript,
assistantSurface,
/if \(props\.initialPromptAutoSubmit !== false\) \{[\s\S]*submitComposer\(\)/
)
})
test('financial assistant toolbar renders isolated assistant sessions without steward entry', () => {
assert.match(assistantScript, /filterAssistantSessionModes\(ASSISTANT_SESSION_MODE_OPTIONS, currentUser\.value\)/)
assert.match(assistantScript, /\.filter\(\(mode\) => mode\.key !== SESSION_TYPE_STEWARD\)/)
assert.match(assistantScript, /mode\.key === SESSION_TYPE_BUDGET/)
assert.match(assistantScript, /visibleModes\.map/)
assert.match(assistantScript, /targetSessionType:\s*mode\.key/)
assert.match(assistantScript, /active:\s*mode\.key === activeSessionType\.value/)
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/)
@@ -193,8 +223,8 @@ test('closing a busy assistant keeps the running instance recoverable', () => {
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\)/)
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', () => {
@@ -259,22 +289,22 @@ test('assistant message action toolbar collects lightweight feedback', () => {
assert.match(messageItemTemplate, /mdi mdi-thumb-down-outline/)
assert.match(assistantScript, /emits:\s*\['close', 'draft-saved', 'request-updated'\]/)
assert.match(appShellRouteView, /@request-updated="handleRequestUpdated"/)
assert.match(assistantScript, /function buildMessageOperationFeedbackContext/)
assert.match(assistantScript, /source:\s*'assistant_message_action'/)
assert.match(assistantScript, /operation_type:\s*message\?\.stewardPlan \? 'steward_message' : 'assistant_message'/)
assert.match(assistantScript, /function shouldShowAssistantMessageActions/)
assert.match(assistantScript, /function copyAssistantMessage/)
assert.match(assistantScript, /function speakAssistantMessage/)
assert.match(assistantScript, /function isMessageFeedbackSelected/)
assert.match(assistantScript, /function submitOperationFeedbackForMessage/)
assert.match(assistantScript, /createOperationFeedback/)
assert.match(assistantScript, /normalizeOperationFeedbackContext/)
assert.match(assistantScript, /submitted:\s*true/)
assert.match(assistantScript, /dismissed:\s*false/)
assert.doesNotMatch(assistantScript, /emit\('operation-completed'/)
assert.match(assistantSurface, /function buildMessageOperationFeedbackContext/)
assert.match(assistantSurface, /source:\s*'assistant_message_action'/)
assert.match(assistantSurface, /operation_type:\s*message\?\.stewardPlan \? 'steward_message' : 'assistant_message'/)
assert.match(assistantSurface, /function shouldShowAssistantMessageActions/)
assert.match(assistantSurface, /function copyAssistantMessage/)
assert.match(assistantSurface, /function speakAssistantMessage/)
assert.match(assistantSurface, /function isMessageFeedbackSelected/)
assert.match(assistantSurface, /function submitOperationFeedbackForMessage/)
assert.match(assistantSurface, /createOperationFeedback/)
assert.match(assistantSurface, /normalizeOperationFeedbackContext/)
assert.match(assistantSurface, /submitted:\s*true/)
assert.match(assistantSurface, /dismissed:\s*false/)
assert.doesNotMatch(assistantSurface, /emit\('operation-completed'/)
assert.match(assistantSubmitComposerScript, /emitOperationCompleted\?\.\(payload/)
assert.match(assistantSubmitComposerScript, /operationFeedback:\s*buildOperationFeedbackState/)
assert.match(assistantSubmitComposerScript, /rating:\s*0/)
assert.match(assistantSubmitResponseModelScript, /rating:\s*0/)
const context = normalizeOperationFeedbackContext(
{

View File

@@ -105,6 +105,14 @@ test('attachment upload association uses conversation selection instead of legac
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
'utf8'
)
const submitAttachmentFlowSource = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelReimbursementSubmitAttachmentFlow.js', import.meta.url)),
'utf8'
)
const submitDraftPreflightSource = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelReimbursementSubmitDraftPreflight.js', import.meta.url)),
'utf8'
)
const flowSource = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementFlow.js', import.meta.url)),
'utf8'
@@ -118,17 +126,17 @@ test('attachment upload association uses conversation selection instead of legac
assert.doesNotMatch(submitComposerSource, /uploadDecisionDialogOpen|hasExistingDocumentEvent|skipUploadDecisionPrompt/)
assert.doesNotMatch(submitComposerSource, /查询可关联草稿失败,已继续按新单据识别/)
assert.match(
submitComposerSource,
submitDraftPreflightSource,
/const claims = await fetchExpenseClaims\(\)[\s\S]*const queryPayload = buildDraftAssociationQueryPayload\(claims\)[\s\S]*meta: \['等待选择关联单据'\][\s\S]*queryPayload/
)
assert.match(submitComposerSource, /meta: \['单据查询失败'\][\s\S]*return null/)
assert.match(submitDraftPreflightSource, /meta: \['单据查询失败'\][\s\S]*return \{ handled: true, value: null \}/)
assert.match(
submitComposerSource,
submitDraftPreflightSource,
/files\.length[\s\S]*!resolvedUploadDisposition[\s\S]*!options\.skipDraftAssociationPrompt[\s\S]*!reviewAction/
)
assert.match(submitComposerSource, /mode:\s*'save_then_associate'/)
assert.match(submitComposerSource, /review_action:\s*'save_draft'[\s\S]*review_action:\s*'link_to_existing_draft'/)
assert.match(submitComposerSource, /appendToCurrentFlow:\s*true/)
assert.match(submitDraftPreflightSource, /mode:\s*'save_then_associate'/)
assert.match(submitAttachmentFlowSource, /review_action:\s*'save_draft'[\s\S]*review_action:\s*'link_to_existing_draft'/)
assert.match(submitAttachmentFlowSource, /appendToCurrentFlow:\s*true/)
assert.match(submitComposerSource, /const appendToCurrentFlow = Boolean\(options\.appendToCurrentFlow\)/)
assert.match(submitComposerSource, /if \(!appendToCurrentFlow\) \{\s*resetFlowRun\(\)\s*\} else \{\s*clearFlowSimulationTimers\(\)/)
assert.match(flowSource, /link_to_existing_draft:\s*\{[\s\S]*key:\s*'attachment-association'/)
@@ -289,14 +297,14 @@ test('receipt files are collected through a single OCR persistence entry before
{ filename: 'invoice.png', kind: 'image', url: 'data:image/png;base64,abc123' }
])
const submitComposerSource = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
const submitRecognitionFlowSource = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelReimbursementSubmitRecognitionFlow.js', import.meta.url)),
'utf8'
)
assert.match(submitComposerSource, /collectReceiptFiles\(/)
assert.doesNotMatch(submitComposerSource, /recognizeOcrFiles\(files,[\s\S]*attachReceiptFolderIdsToFiles/)
assert.doesNotMatch(submitComposerSource, /ocrDocuments = normalizeOcrDocuments\(ocrPayload\)/)
assert.doesNotMatch(submitComposerSource, /ocrFilePreviews = buildOcrFilePreviews\(ocrPayload\)/)
assert.match(submitRecognitionFlowSource, /collectReceiptFiles\(/)
assert.doesNotMatch(submitRecognitionFlowSource, /recognizeOcrFiles\(files,[\s\S]*attachReceiptFolderIdsToFiles/)
assert.doesNotMatch(submitRecognitionFlowSource, /ocrDocuments = normalizeOcrDocuments\(ocrPayload\)/)
assert.doesNotMatch(submitRecognitionFlowSource, /ocrFilePreviews = buildOcrFilePreviews\(ocrPayload\)/)
})
test('file preview cache replaces temporary object urls and never persists them', () => {

View File

@@ -0,0 +1,79 @@
import assert from 'node:assert/strict'
import { readdirSync, readFileSync } from 'node:fs'
import { join, relative } from 'node:path'
import test from 'node:test'
const MAX_SOURCE_UNIT_LINES = 800
const REPO_ROOT = new URL('../..', import.meta.url).pathname
const FRONTEND_SOURCE_ROOTS = [
'web/src/components',
'web/src/composables',
'web/src/utils',
'web/src/views',
]
const FRONTEND_EXTENSIONS = new Set(['.html', '.js', '.mjs', '.ts', '.tsx', '.vue'])
function extensionOf(path) {
const index = path.lastIndexOf('.')
return index === -1 ? '' : path.slice(index)
}
function countLines(source) {
return source.length === 0 ? 0 : source.split('\n').length
}
function walkFiles(root, shouldInclude) {
const result = []
const stack = [root]
while (stack.length > 0) {
const current = stack.pop()
const entries = readdirSync(current, { withFileTypes: true })
for (const entry of entries) {
const path = join(current, entry.name)
if (entry.isDirectory()) {
if (!['node_modules', 'dist', 'build', 'coverage'].includes(entry.name)) {
stack.push(path)
}
continue
}
if (shouldInclude(path)) {
result.push(path)
}
}
}
return result
}
function listOversizedFrontendUnits() {
return FRONTEND_SOURCE_ROOTS.flatMap((root) => {
const absoluteRoot = join(REPO_ROOT, root)
return walkFiles(
absoluteRoot,
(path) => FRONTEND_EXTENSIONS.has(extensionOf(path))
)
})
.map((path) => ({
path: relative(REPO_ROOT, path),
lines: countLines(readFileSync(path, 'utf8')),
}))
.filter((item) => item.lines > MAX_SOURCE_UNIT_LINES)
.sort((left, right) => right.lines - left.lines)
}
test('前端核心组件和模块不超过 800 行', () => {
const oversized = listOversizedFrontendUnits()
assert.deepEqual(
oversized,
[],
`以下前端核心源文件超过 ${MAX_SOURCE_UNIT_LINES} 行:\n${
oversized.map((item) => `- ${item.path}: ${item.lines}`).join('\n')
}`
)
})

View File

@@ -7,6 +7,11 @@ const documentsCenterView = readFileSync(
fileURLToPath(new URL('../src/views/DocumentsCenterView.vue', import.meta.url)),
'utf8'
)
const documentsCenterViewModel = readFileSync(
fileURLToPath(new URL('../src/utils/documentCenterViewModel.js', import.meta.url)),
'utf8'
)
const documentsCenterLogic = `${documentsCenterView}\n${documentsCenterViewModel}`
const documentsCenterStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/views/documents-center-view.css', import.meta.url)),
@@ -53,11 +58,11 @@ test('documents center loading state uses a compact spinner instead of light ban
})
test('documents center top tabs start from all and show document category labels', () => {
assert.match(documentsCenterView, /const DOCUMENT_SCOPE_ALL = '全部'/)
assert.match(documentsCenterView, /const DOCUMENT_SCOPE_APPLICATION = '申请单'/)
assert.match(documentsCenterView, /const DOCUMENT_SCOPE_REIMBURSEMENT = '报销单'/)
assert.match(documentsCenterView, /const DOCUMENT_SCOPE_REVIEW = '审核单'/)
assert.match(documentsCenterView, /const DOCUMENT_SCOPE_ARCHIVE = '归档'/)
assert.match(documentsCenterLogic, /const DOCUMENT_SCOPE_ALL = '全部'/)
assert.match(documentsCenterLogic, /const DOCUMENT_SCOPE_APPLICATION = '申请单'/)
assert.match(documentsCenterLogic, /const DOCUMENT_SCOPE_REIMBURSEMENT = '报销单'/)
assert.match(documentsCenterLogic, /const DOCUMENT_SCOPE_REVIEW = '审核单'/)
assert.match(documentsCenterLogic, /const DOCUMENT_SCOPE_ARCHIVE = '归档'/)
assert.match(documentsCenterView, /const initialScopeTab = resolveInitialScopeTab\(\)/)
assert.match(documentsCenterView, /const activeScopeTab = ref\(initialScopeTab\)/)
assert.match(
@@ -65,16 +70,16 @@ test('documents center top tabs start from all and show document category labels
/function resolveInitialScopeTab\(\) \{[\s\S]*readDocumentCenterQueryText\('dc_scope'\)[\s\S]*return readDocumentScope\(DOCUMENT_SCOPE_ALL, scopeTabs\)/
)
assert.match(
documentsCenterView,
documentsCenterLogic,
/const scopeTabs = \[[\s\S]*DOCUMENT_SCOPE_ALL[\s\S]*DOCUMENT_SCOPE_APPLICATION[\s\S]*DOCUMENT_SCOPE_REIMBURSEMENT[\s\S]*DOCUMENT_SCOPE_REVIEW[\s\S]*DOCUMENT_SCOPE_ARCHIVE[\s\S]*\]/
)
})
test('documents center persists pagination and filters in route query for detail return', () => {
assert.match(documentsCenterView, /import \{ useRoute, useRouter \} from 'vue-router'/)
assert.match(documentsCenterView, /const DOCUMENT_CENTER_QUERY_KEYS = new Set\(/)
assert.match(documentsCenterView, /'dc_page'/)
assert.match(documentsCenterView, /'dc_page_size'/)
assert.match(documentsCenterLogic, /const DOCUMENT_CENTER_QUERY_KEYS = new Set\(/)
assert.match(documentsCenterLogic, /'dc_page'/)
assert.match(documentsCenterLogic, /'dc_page_size'/)
assert.match(documentsCenterView, /const currentPage = ref\(readDocumentCenterQueryNumber\('dc_page', 1\)\)/)
assert.match(documentsCenterView, /const pageSize = ref\(resolveInitialPageSize\(\)\)/)
assert.match(
@@ -91,8 +96,8 @@ test('documents center category tabs map to the intended row sources', () => {
assert.match(documentsCenterView, /excludeArchivedDocumentRows/)
assert.match(documentsCenterView, /approvalRows\.value = excludeArchivedDocumentRows/)
assert.match(documentsCenterView, /const nonArchivedRows = computed\(\(\) => mergeDocumentRows\(\[\.\.\.ownedRows\.value, \.\.\.approvalRows\.value\]\)\)/)
assert.match(documentsCenterView, /import \{ sortDocumentRowsByLatestTime \} from '..\/utils\/documentCenterSort\.js'/)
assert.match(documentsCenterView, /activeScopeTab\.value !== DOCUMENT_SCOPE_ARCHIVE && isArchivedDocumentRow\(row\)/)
assert.match(documentsCenterLogic, /import \{ sortDocumentRowsByLatestTime \} from '\.\/documentCenterSort\.js'/)
assert.match(documentsCenterLogic, /activeScopeTab !== DOCUMENT_SCOPE_ARCHIVE && isArchivedDocumentRow\(row\)/)
assert.match(
documentsCenterView,
/activeScopeTab\.value === DOCUMENT_SCOPE_ALL[\s\S]*return nonArchivedRows\.value/
@@ -118,32 +123,32 @@ test('documents center category tabs map to the intended row sources', () => {
test('documents center sorts every filtered scope by latest document time before pagination', () => {
assert.match(
documentsCenterView,
/return sortDocumentRowsByLatestTime\(activeScopeRows\.value\.filter\(\(row\) => \{[\s\S]*matchesKeyword && matchesDocumentType && matchesScene && matchesRiskLevel && matchesDateRange[\s\S]*\}\)\)/
documentsCenterLogic,
/return sortDocumentRowsByLatestTime\(\(rows \|\| \[\]\)\.filter\(\(row\) => \{[\s\S]*matchesKeyword && matchesDocumentType && matchesScene && matchesRiskLevel && matchesDateRange[\s\S]*\}\)\)/
)
assert.match(
documentsCenterView,
documentsCenterLogic,
/const createdSortTime = resolveDocumentSortTime\(createdAtSource\)[\s\S]*const updatedSortTime = resolveDocumentSortTime\(updatedAtSource\)/
)
assert.match(
documentsCenterView,
documentsCenterLogic,
/createdSortTime,[\s\S]*updatedSortTime,[\s\S]*sortTime: Math\.max\(createdSortTime, updatedSortTime\)/
)
assert.match(documentsCenterView, /return sortDocumentRowsByLatestTime\(Array\.from\(rowMap\.values\(\)\)\)/)
assert.doesNotMatch(documentsCenterView, /right\.sortTime - left\.sortTime/)
assert.match(documentsCenterLogic, /return sortDocumentRowsByLatestTime\(Array\.from\(rowMap\.values\(\)\)\)/)
assert.doesNotMatch(documentsCenterLogic, /right\.sortTime - left\.sortTime/)
})
test('documents center preserves application document type from mapped requests', () => {
assert.match(
documentsCenterView,
documentsCenterLogic,
/const documentTypeCode = normalized\.documentTypeCode \|\| DOCUMENT_TYPE_REIMBURSEMENT/
)
assert.match(
documentsCenterView,
documentsCenterLogic,
/documentTypeCode === DOCUMENT_TYPE_APPLICATION \? '申请单' : '报销单'/
)
assert.doesNotMatch(
documentsCenterView,
documentsCenterLogic,
/documentTypeCode:\s*DOCUMENT_TYPE_REIMBURSEMENT,[\s\S]*documentTypeLabel:\s*'报销单'/
)
})
@@ -169,7 +174,7 @@ test('documents center fetches every paginated claim page for admin-scale lists'
})
test('documents center list shows created time and conditional stay time columns', () => {
assert.match(documentsCenterView, /import \{[\s\S]*formatDocumentListTime[\s\S]*resolveDocumentStayTimeDisplay[\s\S]*\} from '..\/utils\/documentCenterTime\.js'/)
assert.match(documentsCenterLogic, /import \{[\s\S]*formatDocumentListTime[\s\S]*resolveDocumentStayTimeDisplay[\s\S]*\} from '\.\/documentCenterTime\.js'/)
assert.match(documentsCenterView, /<col class="col-created">/)
assert.match(documentsCenterView, /<col v-if="showStayTimeColumn" class="col-stay">/)
assert.match(documentsCenterView, /<col class="col-initiator">/)
@@ -182,9 +187,9 @@ test('documents center list shows created time and conditional stay time columns
documentsCenterView,
/const showStayTimeColumn = computed\(\(\) =>[\s\S]*DOCUMENT_SCOPE_APPLICATION[\s\S]*DOCUMENT_SCOPE_REVIEW/
)
assert.match(documentsCenterView, /createdAtDisplay: formatDocumentListTime\(createdAtSource\)/)
assert.match(documentsCenterView, /stayTimeDisplay: resolveDocumentStayTimeDisplay\(normalized\)/)
assert.match(documentsCenterView, /initiatorName,/)
assert.match(documentsCenterLogic, /createdAtDisplay: formatDocumentListTime\(createdAtSource\)/)
assert.match(documentsCenterLogic, /stayTimeDisplay: resolveDocumentStayTimeDisplay\(normalized\)/)
assert.match(documentsCenterLogic, /initiatorName,/)
assert.match(documentsCenterView, /row\.initiatorName/)
})
@@ -272,7 +277,7 @@ test('documents center can mark all unread documents as read from toolbar', () =
test('documents center rows show NEW marker until the row is opened', () => {
assert.match(documentsCenterView, /<span v-if="row\.isNewDocument" class="new-document-badge">NEW<\/span>/)
assert.match(documentsCenterView, /isNewDocument: archived\s*\?\s*false\s*:\s*isNewDocument\(/)
assert.match(documentsCenterLogic, /isNewDocument: archived\s*\?\s*false\s*:\s*isNewDocument\(/)
assert.match(documentsCenterView, /buildDocumentViewedStatePatch\(row\)/)
assert.match(
documentsCenterView,
@@ -285,7 +290,7 @@ test('documents center rows show NEW marker until the row is opened', () => {
})
test('documents center empty states follow theme tone across all scope tabs', () => {
const emptyStateBlock = documentsCenterView.match(/const emptyState = computed\(\(\) => \{[\s\S]*?\n\}\)/)?.[0] || ''
const emptyStateBlock = documentsCenterLogic.match(/function buildDocumentCenterEmptyState\(options = \{\}\) \{[\s\S]*?\n\}/)?.[0] || ''
assert.match(emptyStateBlock, /eyebrow: '申请单'[\s\S]*tone: 'theme'/)
assert.match(emptyStateBlock, /title: filtered \? '没有符合当前条件的单据'[\s\S]*tone: 'theme'/)
@@ -296,7 +301,7 @@ test('documents center empty states follow theme tone across all scope tabs', ()
})
test('documents center empty states do not render small action buttons', () => {
const emptyStateBlock = documentsCenterView.match(/const emptyState = computed\(\(\) => \{[\s\S]*?\n\}\)/)?.[0] || ''
const emptyStateBlock = documentsCenterLogic.match(/function buildDocumentCenterEmptyState\(options = \{\}\) \{[\s\S]*?\n\}/)?.[0] || ''
assert.match(emptyStateBlock, /actionLabel:\s*''/)
assert.match(emptyStateBlock, /actionIcon:\s*''/)
@@ -308,25 +313,25 @@ test('documents center empty states do not render small action buttons', () => {
})
test('documents center switches filter conditions by category tab', () => {
assert.match(documentsCenterView, /const FILTER_CONFIG_BY_SCOPE = \{/)
assert.match(documentsCenterLogic, /const FILTER_CONFIG_BY_SCOPE = \{/)
assert.match(
documentsCenterView,
documentsCenterLogic,
/\[DOCUMENT_SCOPE_ALL\]: \{[\s\S]*sceneFallbackLabel: '单据场景'[\s\S]*statusTitle: '风险等级'[\s\S]*statusTabs: riskLevelTabs[\s\S]*showDocumentType: true/
)
assert.match(
documentsCenterView,
documentsCenterLogic,
/\[DOCUMENT_SCOPE_APPLICATION\]: \{[\s\S]*sceneFallbackLabel: '申请场景'[\s\S]*statusTitle: '风险等级'[\s\S]*statusTabs: riskLevelTabs[\s\S]*showDocumentType: false/
)
assert.match(
documentsCenterView,
documentsCenterLogic,
/\[DOCUMENT_SCOPE_REIMBURSEMENT\]: \{[\s\S]*statusTitle: '风险等级'[\s\S]*statusTabs: riskLevelTabs[\s\S]*showDocumentType: false/
)
assert.match(
documentsCenterView,
documentsCenterLogic,
/\[DOCUMENT_SCOPE_REVIEW\]: \{[\s\S]*sceneFallbackLabel: '审核场景'[\s\S]*statusTitle: '风险等级'[\s\S]*statusTabs: riskLevelTabs/
)
assert.match(
documentsCenterView,
documentsCenterLogic,
/\[DOCUMENT_SCOPE_ARCHIVE\]: \{[\s\S]*dateLabel: '归档时间'[\s\S]*statusTitle: '风险等级'[\s\S]*statusTabs: riskLevelTabs/
)
assert.match(documentsCenterView, /v-if="showDocumentTypeFilter" class="document-filter"/)
@@ -342,7 +347,7 @@ test('documents center switches filter conditions by category tab', () => {
})
test('documents center risk dropdown derives labels and closes after selection', () => {
assert.match(documentsCenterView, /const riskLevelTabs = \['全部', '高风险', '中风险', '低风险', '无风险'\]/)
assert.match(documentsCenterLogic, /const riskLevelTabs = \['全部', '高风险', '中风险', '低风险', '无风险'\]/)
assert.match(documentsCenterView, /const statusFilterOptions = computed\(\(\) =>/)
assert.match(documentsCenterView, /activeFilterConfig\.value\.statusTabs\.map/)
assert.match(documentsCenterView, /label: tab === '全部' \? '全部风险' : tab/)
@@ -356,10 +361,10 @@ test('documents center risk dropdown derives labels and closes after selection',
test('documents center list renders risk level tags instead of status tags', () => {
assert.match(documentsCenterView, /<th>风险等级<\/th>/)
assert.match(documentsCenterView, /<td data-label="风险等级">[\s\S]*class="risk-level-tags"[\s\S]*v-for="tag in row\.riskTags"/)
assert.match(documentsCenterView, /import \{ countClaimRisks, resolveArchiveRiskTone \} from '..\/utils\/archiveCenterListFilters\.js'/)
assert.match(documentsCenterView, /function buildDocumentRiskMeta\(row\) \{[\s\S]*countClaimRisks\(riskFlags, riskSummary, viewerOptions\)/)
assert.match(documentsCenterView, /riskTone: riskMeta\.tone,[\s\S]*riskLabel: riskMeta\.label,[\s\S]*riskCount: riskMeta\.count,[\s\S]*riskTags: riskMeta\.tags/)
assert.match(documentsCenterView, /function matchesRiskLevelTab\(row, tab\) \{[\s\S]*tab === '高风险'[\s\S]*row\.riskTone === 'high'/)
assert.match(documentsCenterLogic, /import \{ countClaimRisks, resolveArchiveRiskTone \} from '\.\/archiveCenterListFilters\.js'/)
assert.match(documentsCenterLogic, /function buildDocumentRiskMeta\(row, currentUser = null\) \{[\s\S]*countClaimRisks\(riskFlags, riskSummary, viewerOptions\)/)
assert.match(documentsCenterLogic, /riskTone: riskMeta\.tone,[\s\S]*riskLabel: riskMeta\.label,[\s\S]*riskCount: riskMeta\.count,[\s\S]*riskTags: riskMeta\.tags/)
assert.match(documentsCenterLogic, /function matchesRiskLevelTab\(row, tab, activeScopeTab = DOCUMENT_SCOPE_ALL\) \{[\s\S]*tab === '高风险'[\s\S]*row\.riskTone === 'high'/)
assert.match(documentListSharedStyles, /\.risk-level-tags\s*\{[\s\S]*display:\s*inline-flex;/)
assert.match(documentListSharedStyles, /\.risk-level-tag\.high\s*\{[\s\S]*background:\s*#fef2f2;/)
assert.doesNotMatch(documentsCenterView, /<td data-label="状态"><span class="status-tag"/)

View File

@@ -3,10 +3,8 @@ import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
const employeeViewScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/EmployeeManagementView.js', import.meta.url)),
'utf8'
)
import { formatEmployeeHistoryTime } from '../src/views/scripts/employeeManagementModel.js'
const employeeViewTemplate = readFileSync(
fileURLToPath(new URL('../src/views/EmployeeManagementView.vue', import.meta.url)),
'utf8'
@@ -18,31 +16,7 @@ const employeeViewStyles = readFileSync(
'utf8'
)
function extractFormatEmployeeHistoryTime() {
const padMatched = employeeViewScript.match(
/function padDatePart\(value\) \{[\s\S]*?\n\}\n\n(?:export\s+)?function formatEmployeeHistoryTime/
)
assert.ok(padMatched, 'padDatePart should be present before history time formatter')
const matched = employeeViewScript.match(
/(?:export\s+)?function formatEmployeeHistoryTime\(value\) \{[\s\S]*?\n\}\n\nfunction resolveOrganizationUnitCode/
)
assert.ok(matched, 'formatEmployeeHistoryTime should be present before organization helpers')
const padSource = padMatched[0].replace(
/\n\n(?:export\s+)?function formatEmployeeHistoryTime[\s\S]*$/u,
''
)
const source = matched[0].replace(/\n\nfunction resolveOrganizationUnitCode[\s\S]*$/u, '')
return new Function(
'normalizeText',
`${padSource}; ${source}; return formatEmployeeHistoryTime;`
)((value) => String(value || '').trim())
}
test('employee history time uses fixed-width date and minute format', () => {
const formatEmployeeHistoryTime = extractFormatEmployeeHistoryTime()
assert.equal(formatEmployeeHistoryTime('2026年5月6日10时4分'), '2026-05-06 10:04')
assert.equal(formatEmployeeHistoryTime('2026-05-06T10:04:33+08:00'), '2026-05-06 10:04')
assert.equal(formatEmployeeHistoryTime('2026-05-06 10:04'), '2026-05-06 10:04')

View File

@@ -64,10 +64,18 @@ import {
import { useTravelReimbursementFlow } from '../src/views/scripts/useTravelReimbursementFlow.js'
import { useApplicationPreviewEditor } from '../src/views/scripts/useApplicationPreviewEditor.js'
const submitComposerScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.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 stewardServiceScript = readFileSync(
fileURLToPath(new URL('../src/services/steward.js', import.meta.url)),
'utf8'
@@ -1201,14 +1209,11 @@ test('application submit result does not render reimbursement review followup',
test('steward streaming uses chunked typewriter to reduce perceived latency', () => {
assert.match(stewardPlanFlowScript, /STEWARD_THINKING_TYPEWRITER_CHUNK_SIZE = 5/)
assert.match(stewardPlanFlowScript, /resolveStewardTypewriterNextIndex\(chars, index\)/)
assert.match(stewardPlanFlowScript, /index = Math\.min\(chars\.length, index \+ STEWARD_THINKING_TYPEWRITER_CHUNK_SIZE\)/)
assert.match(stewardPlanFlowScript, /resolveStewardTypewriterNextIndex\(chars, index, STEWARD_THINKING_TYPEWRITER_CHUNK_SIZE\)/)
assert.match(submitComposerScript, /STEWARD_DELEGATED_THINKING_CHUNK_SIZE = 5/)
assert.match(submitComposerScript, /resolveStewardTypewriterNextIndex\(chars, index\)/)
assert.match(submitComposerScript, /index = Math\.min\(chars\.length, index \+ STEWARD_DELEGATED_THINKING_CHUNK_SIZE\)/)
assert.match(submitComposerScript, /resolveStewardTypewriterNextIndex\(chars, index, STEWARD_DELEGATED_THINKING_CHUNK_SIZE\)/)
assert.match(stewardFollowupFlowScript, /STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE = 5/)
assert.match(stewardFollowupFlowScript, /resolveStewardTypewriterNextIndex\(chars, index\)/)
assert.match(stewardFollowupFlowScript, /index = Math\.min\(chars\.length, index \+ STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE\)/)
assert.match(stewardFollowupFlowScript, /resolveStewardTypewriterNextIndex\(chars, index, STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE\)/)
})
test('steward typewriter renders markdown table blocks at once', () => {

View File

@@ -0,0 +1,54 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { syncExpenseClaimFilesToDraft } from '../src/utils/expenseClaimAttachmentSync.js'
function createFile(name) {
return { name }
}
test('自动归集附件时优先上传到空附件明细项', async () => {
const uploads = []
const result = await syncExpenseClaimFilesToDraft({
claimId: 'claim-1',
files: [createFile('2月20 武汉-上海.pdf'), createFile('2月23 上海-武汉.pdf')],
fetchExpenseClaimDetail: async () => ({
items: [
{ id: 'item-1', invoice_id: '' },
{ id: 'item-2', invoice_id: '' }
]
}),
createExpenseClaimItem: async () => ({ items: [] }),
uploadExpenseClaimItemAttachment: async (claimId, itemId, file) => {
uploads.push({ claimId, itemId, fileName: file.name })
}
})
assert.equal(result.uploadedCount, 2)
assert.equal(result.skippedCount, 0)
assert.deepEqual(uploads, [
{ claimId: 'claim-1', itemId: 'item-1', fileName: '2月20 武汉-上海.pdf' },
{ claimId: 'claim-1', itemId: 'item-2', fileName: '2月23 上海-武汉.pdf' }
])
})
test('已有明细不足时自动创建明细再上传附件', async () => {
const uploads = []
const result = await syncExpenseClaimFilesToDraft({
claimId: 'claim-1',
files: [createFile('补充票据.pdf')],
fetchExpenseClaimDetail: async () => ({ items: [] }),
createExpenseClaimItem: async () => ({
items: [{ id: 'created-item-1', invoice_id: '' }]
}),
uploadExpenseClaimItemAttachment: async (claimId, itemId, file) => {
uploads.push({ claimId, itemId, fileName: file.name })
}
})
assert.equal(result.uploadedCount, 1)
assert.equal(result.skippedCount, 0)
assert.deepEqual(uploads, [
{ claimId: 'claim-1', itemId: 'created-item-1', fileName: '补充票据.pdf' }
])
})

View File

@@ -13,6 +13,11 @@ const radarChart = readFileSync(
'utf8'
)
const modalStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/components/expense-profile-detail-modal.css', import.meta.url)),
'utf8'
)
test('expense profile modal remounts the behavior radar when opened', () => {
assert.match(modal, /destroy-on-close/)
assert.match(modal, /<RadarChart/)
@@ -24,14 +29,14 @@ test('expense profile modal remounts the behavior radar when opened', () => {
test('expense profile modal uses compact laptop dialog sizing', () => {
assert.match(modal, /width="min\(960px, calc\(100vw - 64px\)\)"/)
assert.match(modal, /max-height:\s*min\(580px, calc\(100dvh - 176px\)\)/)
assert.match(modalStyles, /max-height:\s*min\(580px, calc\(100dvh - 176px\)\)/)
assert.match(
modal,
modalStyles,
/@media \(min-width: 861px\) and \(max-width: 1440px\),\s*\n\s*\(min-width: 861px\) and \(max-height: 820px\)/
)
assert.match(modal, /width:\s*min\(900px, calc\(100vw - 96px\)\) !important;/)
assert.match(modal, /max-height:\s*min\(520px, calc\(100dvh - 152px\)\)/)
assert.match(modal, /\.profile-radar-chart \{[\s\S]*height:\s*248px;/)
assert.match(modalStyles, /width:\s*min\(900px, calc\(100vw - 96px\)\) !important;/)
assert.match(modalStyles, /max-height:\s*min\(520px, calc\(100dvh - 152px\)\)/)
assert.match(modalStyles, /\.profile-radar-chart \{[\s\S]*height:\s*248px;/)
})
test('radar chart uses the shared echarts lifecycle and enables entrance animation', () => {

View File

@@ -13,6 +13,10 @@ const overviewViewModel = readFileSync(
fileURLToPath(new URL('../src/composables/useOverviewView.js', import.meta.url)),
'utf8'
)
const overviewDisplayModel = readFileSync(
fileURLToPath(new URL('../src/composables/overviewViewDisplayModel.js', import.meta.url)),
'utf8'
)
const analyticsService = readFileSync(
fileURLToPath(new URL('../src/services/analytics.js', import.meta.url)),
'utf8'
@@ -68,7 +72,7 @@ test('daily amount trend uses stacked category bars with clear unit and legend',
assert.match(overviewView, /:key="`finance-count-\$\{financeDashboardRenderKey\}`"/)
assert.match(overviewView, /return financeDashboardLoading\.value\s*\n\}/)
assert.doesNotMatch(overviewView, /financeDashboardLoading\.value && !financeDashboardLoaded\.value/)
assert.match(overviewViewModel, /categoryAmountSeries: \[\]/)
assert.match(overviewDisplayModel, /categoryAmountSeries: \[\]/)
assert.match(overviewViewModel, /financeDashboardRenderKey/)
assert.match(overviewViewModel, /financeDashboardRequestSeq/)
assert.match(overviewViewModel, /requestSeq !== financeDashboardRequestSeq/)

View File

@@ -24,6 +24,14 @@ const overviewViewModel = readFileSync(
fileURLToPath(new URL('../src/composables/useOverviewView.js', import.meta.url)),
'utf8'
)
const overviewDisplayModel = readFileSync(
fileURLToPath(new URL('../src/composables/overviewViewDisplayModel.js', import.meta.url)),
'utf8'
)
const overviewRangeModel = readFileSync(
fileURLToPath(new URL('../src/composables/overviewViewRangeModel.js', import.meta.url)),
'utf8'
)
const overviewTemplate = readFileSync(
fileURLToPath(new URL('../src/views/OverviewView.vue', import.meta.url)),
'utf8'
@@ -110,7 +118,7 @@ test('risk dashboard localizes backend metric keys before rendering', () => {
)
assert.match(riskLabels, /travel:/)
assert.match(riskLabels, /rule_center:/)
assert.match(overviewViewModel, /formatRiskSignalLabel/)
assert.match(overviewDisplayModel, /formatRiskSignalLabel/)
assert.match(overviewViewModel, /riskCompositionLegend/)
assert.match(overviewViewModel, /signalDistribution/)
assert.doesNotMatch(overviewViewModel, /formatRiskSourceLabel/)
@@ -145,10 +153,10 @@ test('overview custom date defaults use current year instead of hard-coded legac
})
test('risk daily trend is bucketed for long ranges and keeps chart labels readable', () => {
assert.match(overviewViewModel, /RISK_DAILY_TREND_MAX_BUCKETS = 14/)
assert.match(overviewRangeModel, /RISK_DAILY_TREND_MAX_BUCKETS = 14/)
assert.match(overviewViewModel, /aggregateRiskDailyTrendRows/)
assert.match(overviewViewModel, /Math\.ceil\(normalizedRows\.length \/ maxBuckets\)/)
assert.match(overviewViewModel, /buildRiskTrendBucketLabel/)
assert.match(overviewRangeModel, /Math\.ceil\(normalizedRows\.length \/ maxBuckets\)/)
assert.match(overviewRangeModel, /buildRiskTrendBucketLabel/)
assert.doesNotMatch(overviewViewModel, /rows\.slice\(-7\)/)
assert.match(riskDailyTrendChart, /const barWidth = computed/)
assert.match(riskDailyTrendChart, /barMaxWidth: 14/)

View File

@@ -85,6 +85,10 @@ const submitComposerScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
'utf8'
)
const messageHandlersScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementCreateViewMessageHandlers.js', import.meta.url)),
'utf8'
)
test('assistant session modes expose independent quick actions', () => {
assert.deepEqual(
@@ -451,10 +455,10 @@ test('guided flow is local until final confirmation or collected query handoff',
assert.doesNotMatch(guidedFlowScript, /meta: \['缺少可关联申请单'\],[\s\S]{0,120}suggestedActions: buildGuidedExpenseTypeActions\(\)/)
assert.match(guidedFlowScript, /handleSceneSelectionApplicationGate/)
assert.match(createViewScript, /handleSceneSelectionApplicationGate/)
assert.match(createViewScript, /if \(await handleGuidedComposerSubmit\(options\)\) \{[\s\S]*return null[\s\S]*\}[\s\S]*return submitComposerInternal\(options\)/)
assert.match(createViewScript, /submitComposerFromMessageHandlers/)
assert.match(messageHandlersScript, /if \(await handleGuidedComposerSubmit\(options\)\) return null[\s\S]*return submitComposerInternal\(options\)/)
assert.match(suggestedActionsScript, /ASSISTANT_SCOPE_ACTION_SWITCH/)
assert.match(suggestedActionsScript, /actionPayload\.carry_text/)
assert.match(createViewScript, /targetSessionType === SESSION_TYPE_EXPENSE[\s\S]*carryText === '我要报销'[\s\S]*pushExpenseSceneSelectionPrompt\(carryText\)/)
assert.match(suggestedActionsScript, /targetSessionType === SESSION_TYPE_EXPENSE[\s\S]*carryText === '我要报销'[\s\S]*pushExpenseSceneSelectionPrompt\(carryText\)/)
assert.match(submitComposerScript, /resolveAssistantScopeGuard/)
assert.match(submitComposerScript, /skipScopeGuard/)

View File

@@ -23,6 +23,19 @@ 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'
@@ -35,6 +48,11 @@ 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'
@@ -43,6 +61,10 @@ 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'
@@ -51,14 +73,26 @@ const reviewDrawerScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementReviewDrawer.js', import.meta.url)),
'utf8'
)
const submitComposerScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.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'
@@ -85,31 +119,31 @@ const insightPanelStyles = readFileSync(
)
test('review drawer tools expose the default review tab before conditional document and risk tabs', () => {
assert.match(createViewTemplate, /v-if="activeReviewPayload && reviewOverviewDrawerAvailable"[\s\S]*title="报销识别核对"[\s\S]*@click="switchToReviewOverviewDrawer"/)
assert.match(createViewTemplate, /v-if="activeReviewPayload && reviewDocumentDrawerAvailable"[\s\S]*title="单据识别"/)
assert.match(createViewTemplate, /v-if="activeReviewPayload && reviewRiskDrawerAvailable"[\s\S]*title="显示风险"/)
assert.match(createViewTemplate, /title="调用流程"/)
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(
createViewTemplate.indexOf('title="报销识别核对"') < createViewTemplate.indexOf('title="单据识别"'),
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(createViewScript, /const isReviewOverviewDrawer = computed\(\(\) => reviewDrawerMode\.value === REVIEW_DRAWER_MODE_REVIEW\)/)
assert.match(createViewScript, /function switchReviewDrawerMode\(mode\) \{[\s\S]*if \(reviewDrawerMode\.value === mode\) \{[\s\S]*return[\s\S]*\}/)
assert.match(createViewScript, /function switchToReviewOverviewDrawer\(\) \{[\s\S]*switchReviewDrawerMode\(REVIEW_DRAWER_MODE_REVIEW\)/)
assert.match(createViewScript, /function toggleReviewDocumentDrawer\(\) \{[\s\S]*switchReviewDrawerMode\(REVIEW_DRAWER_MODE_DOCUMENTS\)/)
assert.match(createViewScript, /function toggleReviewRiskDrawer\(\) \{[\s\S]*switchReviewDrawerMode\(REVIEW_DRAWER_MODE_RISK\)/)
assert.match(createViewScript, /function toggleReviewFlowDrawer\(\) \{[\s\S]*switchReviewDrawerMode\(REVIEW_DRAWER_MODE_FLOW\)/)
assert.doesNotMatch(createViewScript, /REVIEW_DRAWER_MODE_DOCUMENTS\s*\?\s*REVIEW_DRAWER_MODE_REVIEW/)
assert.doesNotMatch(createViewScript, /REVIEW_DRAWER_MODE_RISK\s*\?\s*REVIEW_DRAWER_MODE_REVIEW/)
assert.doesNotMatch(createViewScript, /REVIEW_DRAWER_MODE_FLOW\s*\?\s*REVIEW_DRAWER_MODE_REVIEW/)
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(createViewTemplate, /class="insight-body"[\s\S]*:class="\{ 'document-review-body': isReviewDocumentDrawer \}"/)
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;/)
@@ -146,8 +180,8 @@ test('document review OCR result card header keeps copy and navigation separated
})
test('document preview avoids restored stale object urls', () => {
assert.match(createViewTemplate, /v-if="documentPreviewDialog\.kind === 'image'"[\s\S]*:key="documentPreviewDialog\.renderKey"/)
assert.match(createViewTemplate, /v-else-if="documentPreviewDialog\.kind === 'pdf'"[\s\S]*:key="documentPreviewDialog\.renderKey"/)
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/)
@@ -182,8 +216,8 @@ test('local transport review no longer uses the travel hotel template', () => {
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(createViewTemplate, /placeholder="例如:出租车\/网约车票据 \/ 火车\/高铁票"/)
assert.doesNotMatch(createViewTemplate, /票据场景[\s\S]{0,260}例如:业务招待费 \/ 差旅费/)
assert.match(createViewTemplateSurface, /placeholder="例如:出租车\/网约车票据 \/ 火车\/高铁票"/)
assert.doesNotMatch(createViewTemplateSurface, /票据场景[\s\S]{0,260}例如:业务招待费 \/ 差旅费/)
})
test('local save of changed reimbursement category updates edit fields too', () => {
@@ -259,19 +293,19 @@ test('next step action uses rich text guidance and confirm dialog instead of foo
)
assert.doesNotMatch(highRiskCopy, /\[继续下一步\]\(#review-next-step\)/)
assert.match(createViewTemplate, /class="review-next-step-rich-copy message-answer-markdown"[\s\S]*renderMarkdown\(buildReviewNextStepRichCopyForMessage\(message\)\)/)
assert.match(createViewTemplate, /class="message-bubble" :class="buildMessageBubbleClass\(message\)"/)
assert.match(createViewTemplate, /:open="nextStepConfirmDialog\.open"[\s\S]*title="确认提交当前单据?"[\s\S]*confirm-text="确认提交"/)
assert.match(createViewScript, /const REVIEW_NEXT_STEP_HREF = '#review-next-step'/)
assert.match(createViewScript, /buildReviewRiskLevelCounts/)
assert.match(createViewScript, /function buildMessageBubbleClass\(message\)/)
assert.match(createViewScript, /message-bubble-review-risk-high/)
assert.match(createViewScript, /message-bubble-review-risk-medium/)
assert.match(createViewScript, /message-bubble-review-risk-low/)
assert.match(createViewScript, /function openReviewNextStepConfirm\(message\)/)
assert.match(createViewScript, /async function confirmReviewNextStepSubmit\(\)/)
assert.match(createViewScript, /href === REVIEW_NEXT_STEP_HREF[\s\S]*openReviewNextStepConfirm\(message\)/)
assert.match(createViewScript, /href\.startsWith\(REVIEW_RISK_PANEL_HREF_PREFIX\)[\s\S]*switchReviewDrawerMode\(REVIEW_DRAWER_MODE_RISK\)/)
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,/)
@@ -286,11 +320,11 @@ test('review risk drawer lists risk briefs without score and posts details into
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(createViewTemplate, /review-side-risk-score/)
assert.doesNotMatch(createViewTemplate, /风险评分/)
assert.doesNotMatch(createViewTemplate, /暂无风险评分/)
assert.doesNotMatch(createViewScript, /function buildReviewRiskScore/)
assert.doesNotMatch(createViewScript, /const reviewRiskScore/)
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(
@@ -299,11 +333,11 @@ test('review risk drawer lists risk briefs without score and posts details into
)
assert.match(
createViewTemplate,
/class="review-side-risk-item"[\s\S]*@click="appendReviewRiskBriefToConversation\(item\)"/
createViewTemplateSurface,
/class="review-side-risk-item"[\s\S]*@click="ui\.appendReviewRiskBriefToConversation\(item\)"/
)
assert.doesNotMatch(createViewTemplate, /\{\{\s*item\.levelLabel\s*\}\}/)
assert.match(createViewTemplate, /class="review-side-risk-icon" :title="item\.levelLabel"/)
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*'低风险'/)
@@ -315,22 +349,22 @@ test('review risk drawer lists risk briefs without score and posts details into
assert.match(reviewPanelModelScript, /\.replace\(\/\(高风险\|中风险\|低风险\)\/g,\s*''\)/)
assert.match(reviewPanelModelScript, /sourceLabel:\s*meta\.label/)
assert.doesNotMatch(reviewPanelModelScript, /normalizedTitle\.includes\('AI预审'\)/)
assert.match(createViewScript, /metaTone:\s*item\.level \|\| 'low'/)
assert.doesNotMatch(createViewTemplate, /@click="openReviewRiskDetail\(item\)"/)
assert.doesNotMatch(createViewTemplate, /review-risk-detail-modal/)
assert.doesNotMatch(createViewScript, /reviewRiskDetailDialog/)
assert.doesNotMatch(createViewScript, /function openReviewRiskDetail/)
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(
createViewScript,
createViewScriptSurface,
/function appendReviewRiskBriefToConversation\(item\) \{[\s\S]*messages\.value\.push\(createMessage\('assistant'/
)
assert.match(reviewPanelModelScript, /function buildReviewRiskConversationText\(item, detailTarget = \{\}\)/)
assert.match(createViewScript, /function resolveReviewDetailTarget\(message = null\) \{[\s\S]*router\.resolve\(\{[\s\S]*name: 'app-document-detail'/)
assert.match(createViewScript, /function resolveReviewRiskDetailTarget\(\) \{[\s\S]*return resolveReviewDetailTarget\(\)/)
assert.match(createViewScript, /进入 \$\{claimNo\} 详情重新填写/)
assert.match(createViewTemplate, /class="expense-query-risk-row"[\s\S]*appendExpenseQueryRiskToConversation\(record, risk\)/)
assert.match(createViewScript, /function appendExpenseQueryRiskToConversation\(record, risk\) \{[\s\S]*进入 \$\{claimNo\} 详情重新填写/)
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', () => {
@@ -340,15 +374,15 @@ test('review drawer default mode is scoped by the current action and travel over
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(createViewScript, /canExposeReviewPanelScope\(item\.reviewPanelScope\)/)
assert.match(createViewScript, /currentInsight\.value\.intent === 'agent' && agent[\s\S]*return null/)
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(createViewTemplate, /item\.editor === 'textarea'[\s\S]*<textarea/)
assert.match(createViewTemplate, /wide: item\.wide/)
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', () => {
@@ -356,17 +390,17 @@ test('submit composer scopes the side panel to intent overview, document upload,
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: resolveReviewPanelScope\(\{/)
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.match(createViewTemplate, /!message\.reviewPayload && !message\.queryPayload && message\.meta\?\.length/)
assert.match(createViewTemplate, /!message\.reviewPayload && !message\.queryPayload && message\.suggestedActions\?\.length/)
assert.match(createViewTemplate, /!message\.reviewPayload && !message\.queryPayload && message\.citations\?\.length/)
assert.match(createViewTemplate, /message\.queryPayload\.title \|\| \(message\.queryPayload\.selectionMode === 'draft_association' \? '选择关联草稿' : '最近 5 条筛选结果'\)/)
assert.match(createViewTemplate, /v-html="renderMarkdown\(buildExpenseQueryHint\(message\.queryPayload\)\)"/)
assert.match(createViewScript, /href\.startsWith\('\/app\/'\)[\s\S]*router\.push\(href\)/)
assert.doesNotMatch(createViewTemplateSurface, /message\.meta\?\.length/)
assert.match(createViewTemplateSurface, /!message\.reviewPayload && !message\.queryPayload && 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', () => {
@@ -384,36 +418,36 @@ test('backend query response suppresses generic query actions and supports archi
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": f"最近 \{len\(preview_claims\)\} 条\{scope_label\}"/)
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(createViewScript, /const closeAfterBusy = ref\(false\)/)
assert.match(createViewScript, /function isWorkbenchBusy\(\) \{[\s\S]*submitting\.value \|\| reviewActionBusy\.value \|\| sessionSwitchBusy\.value/)
assert.match(createViewScript, /function maybeFinalizeDeferredClose\(\) \{[\s\S]*!closeAfterBusy\.value \|\| workbenchVisible\.value \|\| isWorkbenchBusy\(\)/)
assert.match(createViewScript, /function requestCloseWorkbench\(\) \{[\s\S]*closeAfterBusy\.value = isWorkbenchBusy\(\)[\s\S]*workbenchVisible\.value = false/)
assert.match(createViewScript, /function emitCloseAfterLeave\(\) \{[\s\S]*closeAfterBusy\.value && isWorkbenchBusy\(\)[\s\S]*return/)
assert.match(createViewScript, /\[submitting\.value, reviewActionBusy\.value, sessionSwitchBusy\.value, workbenchVisible\.value\][\s\S]*maybeFinalizeDeferredClose\(\)/)
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(createViewTemplate, /v-if="canShowTravelCalculator" class="travel-calculator-anchor"/)
assert.match(createViewTemplate, /class="tool-btn composer-side-btn travel-calculator-trigger"[\s\S]*差旅计算器/)
assert.match(createViewTemplate, /class="travel-calculator-popover"[\s\S]*v-model="travelCalculatorForm\.days"[\s\S]*v-model="travelCalculatorForm\.location"/)
assert.doesNotMatch(createViewTemplate, /travel-calculator-modal/)
assert.doesNotMatch(createViewTemplate, /travelCalculatorResult\.total_amount/)
assert.match(createViewScript, /calculateTravelReimbursement/)
assert.match(createViewScript, /const canShowTravelCalculator = computed\(\(\) => activeSessionType\.value === SESSION_TYPE_EXPENSE\)/)
assert.match(createViewScript, /function openTravelCalculator\(\) \{[\s\S]*!canShowTravelCalculator\.value[\s\S]*closeTravelCalculator\(\)/)
assert.match(createViewScript, /function toggleTravelCalculator\(\)/)
assert.match(createViewScript, /function toggleTravelCalculator\(\) \{[\s\S]*!canShowTravelCalculator\.value[\s\S]*closeTravelCalculator\(\)/)
assert.match(createViewScript, /watch\(canShowTravelCalculator,[\s\S]*closeTravelCalculator\(\)/)
assert.match(createViewScript, /function submitTravelCalculator\(\) \{[\s\S]*calculateTravelReimbursement\(\{[\s\S]*grade: String\(user\.grade/)
assert.match(createViewScript, /根据您输入的地点和天数/)
assert.match(createViewScript, /匹配到您要出差的地区为/)
assert.match(createViewScript, /参考可报销合计/)
assert.match(createViewScript, /住宿费:\$\{hotelRate\} × \$\{days\} = \$\{hotelAmount\} 元/)
assert.match(createViewScript, /messages\.value\.push\(createMessage\('assistant', buildTravelCalculatorResultText\(payload\)/)
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/)
})
@@ -421,11 +455,11 @@ 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(
createViewScript,
submitComposerScript,
/resolvedUploadDisposition === 'continue_existing'[\s\S]*buildReviewFormContextFromPayload\([\s\S]*activeReviewPayload\.value[\s\S]*reviewInlineForm\.value[\s\S]*extraContext\.review_form_values/s
)
assert.match(
createViewScript,
submitComposerScript,
/inheritedReviewContext\.business_time_context[\s\S]*extraContext\.business_time_context = inheritedReviewContext\.business_time_context/s
)
})
@@ -465,27 +499,31 @@ test('review form context emits ontology fields instead of local aliases', () =>
})
test('review drawer save action is disabled while receipt recognition is submitting', () => {
assert.match(createViewScript, /const submitting = ref\(false\)/)
assert.match(createViewScriptSurface, /const submitting = ref\(false\)/)
assert.match(
createViewScript,
/submitting\.value = true[\s\S]*recognizeOcrFiles\(files\)[\s\S]*submitting\.value = false/s
submitComposerScript,
/submitting\.value = true[\s\S]*handleSubmitRecognitionFlow\(\{[\s\S]*recognizeOcrFiles[\s\S]*submitting\.value = false/s
)
assert.match(
createViewTemplate,
/class="review-side-save-pill"[\s\S]*:disabled="reviewActionBusy \|\| submitting"[\s\S]*@click="saveInlineReviewChanges"/
submitComposerScript,
/collectReceiptFiles\(\{[\s\S]*files,[\s\S]*recognizeOcrFiles[\s\S]*\}\)/
)
assert.match(
createViewScript,
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(
createViewScript,
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(reimbursementFlowScript, /FLOW_RUN_DETAIL_REFRESH_TIMEOUT_MS\s*=\s*3000/)
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\)/
@@ -509,10 +547,10 @@ test('draft creation keeps detail-scoped attachment persistence alive before clo
)
assert.match(submitComposerScript, /source: 'detail-smart-entry-attachment-sync'/)
assert.match(submitComposerScript, /uploadedCount: Number\(syncResult\?\.uploadedCount \|\| 0\)/)
assert.match(attachmentsScript, /function normalizeAttachmentMatchName\(value\)/)
assert.match(attachmentsScript, /const normalizedMatchBuckets = new Map\(\)/)
assert.match(attachmentSyncScript, /function normalizeAttachmentMatchName\(value\)/)
assert.match(attachmentSyncScript, /const normalizedMatchBuckets = new Map\(\)/)
assert.match(
attachmentsScript,
attachmentSyncScript,
/nextExactMatch \|\| nextNormalizedMatch \|\| fallbackMatch \|\| emptyFallbackMatch/
)
})
@@ -593,8 +631,8 @@ test('detail smart-entry receipt sync uploads files to existing empty items and
test('review summary renders markdown and save draft relies on backend response only', () => {
assert.match(
createViewTemplate,
/message\.text && message\.role === 'assistant' && message\.reviewPayload[\s\S]*v-html="renderMarkdown\(message\.text\)"/
createViewTemplateSurface,
/message\.text && message\.role === 'assistant' && message\.reviewPayload[\s\S]*v-html="ui\.renderMarkdown\(ui\.buildReviewMainMessageText\(message\)\)"/
)
assert.doesNotMatch(
reviewActionsScript,
@@ -623,13 +661,13 @@ test('saved draft review messages stop showing the save-draft prompt', () => {
assert.doesNotMatch(followup.summary, /当前草稿待完善|必须/)
assert.doesNotMatch(followup.summary, /点击|点“草稿”|保存为草稿|临时保存|暂存/)
assert.match(messageItemTemplate, /buildReviewPlainFollowupForMessage\(message\)/)
assert.match(createViewScript, /function isDraftSavedReviewMessage\(message\)/)
assert.match(createViewScript, /function canUseInlineSaveDraft\(message\)[\s\S]*isDraftSavedReviewMessage\(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(
createViewScript,
createViewScriptSurface,
/emitDraftSaved:\s*\(payload\)\s*=>\s*emit\('draft-saved', payload\)/
)
assert.match(submitComposerScript, /function emitSavedDraftRefresh\(draftPayload\)/)
@@ -637,10 +675,10 @@ test('guided save draft emits refresh and exposes reimbursement draft detail car
submitComposerScript,
/emitSavedDraftRefresh\(payload\?\.result\?\.draft_payload \|\| null\)/
)
assert.match(createViewScript, /function shouldShowDraftSavedCard\(message\)/)
assert.match(createViewScript, /function canOpenDraftDetail\(message\)/)
assert.match(createViewScript, /function resolveReimbursementDraftClaimNo\(draftPayload\)/)
assert.doesNotMatch(createViewScript, /function buildReimbursementDraftSummaryItems\(draftPayload\)/)
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/)

View File

@@ -36,7 +36,7 @@ const detailViewTemplate = readFileSync(
fileURLToPath(new URL('../src/views/TravelRequestDetailView.vue', import.meta.url)),
'utf8'
)
const detailViewScript = readFileSync(
const detailViewComponentScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/TravelRequestDetailView.js', import.meta.url)),
'utf8'
)
@@ -44,10 +44,53 @@ const detailViewInsights = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelRequestDetailInsights.js', import.meta.url)),
'utf8'
)
const detailAiAdviceModelScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelRequestDetailAiAdviceModel.js', import.meta.url)),
'utf8'
)
const detailExpenseModelScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelRequestDetailExpenseModel.js', import.meta.url)),
'utf8'
)
const detailViewSetupScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelRequestDetailSetup.js', import.meta.url)),
'utf8'
)
const detailSmartEntryScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelRequestDetailSmartEntryRecognition.js', import.meta.url)),
'utf8'
)
const detailAttachmentPreviewScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelRequestDetailAttachmentPreview.js', import.meta.url)),
'utf8'
)
const detailExpenseEditorScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelRequestDetailExpenseEditor.js', import.meta.url)),
'utf8'
)
const detailRiskSubmitScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelRequestDetailRiskSubmit.js', import.meta.url)),
'utf8'
)
const detailApprovalFlowScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelRequestDetailApprovalFlow.js', import.meta.url)),
'utf8'
)
const detailEmployeeRiskProfileScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelRequestEmployeeRiskProfile.js', import.meta.url)),
'utf8'
)
const detailViewScript = [
detailViewComponentScript,
detailViewSetupScript,
detailSmartEntryScript,
detailAttachmentPreviewScript,
detailExpenseEditorScript,
detailRiskSubmitScript,
detailApprovalFlowScript,
detailEmployeeRiskProfileScript,
detailExpenseModelScript
].join('\n')
const detailViewStyle = readFileSync(
fileURLToPath(new URL('../src/assets/styles/views/travel-request-detail-view.css', import.meta.url)),
'utf8'
@@ -76,6 +119,14 @@ const stageRiskAdviceStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/components/stage-risk-advice-card.css', import.meta.url)),
'utf8'
)
const relatedApplicationCardTemplate = readFileSync(
fileURLToPath(new URL('../src/components/travel/TravelRequestRelatedApplicationCard.vue', import.meta.url)),
'utf8'
)
const progressCardTemplate = readFileSync(
fileURLToPath(new URL('../src/components/travel/TravelRequestProgressCard.vue', import.meta.url)),
'utf8'
)
const attachmentMeta = {
file_name: 'taxi-invoice.pdf',
@@ -679,7 +730,7 @@ test('AI advice risk section keeps compact risk prompt styling', () => {
assert.doesNotMatch(detailViewTemplate, /risk-card-tag-list/)
assert.doesNotMatch(detailViewTemplate, /risk-note-tag/)
assert.match(detailViewScript, /tags: resolveRiskTags\(card\)/)
assert.match(detailViewInsights, /const sortedRiskCards = sortRiskCardsByTone\(normalizedRiskCards\)/)
assert.match(detailAiAdviceModelScript, /const sortedRiskCards = sortRiskCardsByTone\(normalizedRiskCards\)/)
assert.doesNotMatch(detailViewInsights, /visibleRiskCards/)
assert.doesNotMatch(detailViewInsights, /hiddenCount/)
assert.match(detailViewStyle, /\.validation-card \{\s*border: 1px solid #e5e7eb;/)
@@ -988,16 +1039,17 @@ test('expense detail table shows the amount total below detail rows', () => {
test('related application information is shown above expense details for reimbursement check', () => {
assert.ok(
detailViewTemplate.indexOf('<h3>关联单据信息</h3>')
detailViewTemplate.indexOf('<TravelRequestRelatedApplicationCard')
< detailViewTemplate.indexOf("isApplicationDocument ? '申请详情' : '费用明细'")
)
assert.match(detailViewTemplate, /<article v-if="!isApplicationDocument" class="detail-card panel">/)
assert.match(detailViewTemplate, /展示本次报销关联的前置申请/)
assert.match(detailViewTemplate, /relatedApplicationFactItems/)
assert.match(detailViewTemplate, /暂未识别到关联申请单/)
assert.match(relatedApplicationCardTemplate, /<article v-if="!isApplicationDocument" class="detail-card panel">/)
assert.match(relatedApplicationCardTemplate, /<h3>关联单据信息<\/h3>/)
assert.match(relatedApplicationCardTemplate, /展示本次报销关联的前置申请/)
assert.match(relatedApplicationCardTemplate, /relatedApplicationFactItems/)
assert.match(relatedApplicationCardTemplate, /暂未识别到关联申请单/)
assert.match(detailViewScript, /buildRelatedApplicationFactItems/)
assert.match(requestsComposableScript, /const RELATED_APPLICATION_STEP_LABEL = '关联单据'/)
assert.match(requestsComposableScript, /const ARCHIVED_STEP_LABEL = '已归档'/)
assert.match(detailExpenseModelScript, /label:\s*'关联单据'/)
assert.match(detailExpenseModelScript, /label:\s*'已归档'/)
assert.match(detailViewStyle, /\.related-application-empty/)
assert.doesNotMatch(detailViewTemplate, /v-if="canEditDetailNote" class="detail-note-editor"/)
assert.doesNotMatch(detailViewTemplate, /v-model="detailNoteEditorView"/)
@@ -1079,8 +1131,6 @@ test('travel item date caption distinguishes departure return and trip events',
assert.match(detailViewScript, /labels\.set\(item\.id, '返回时间'\)/)
assert.match(detailViewScript, /return '乘车时间'/)
assert.match(detailViewScript, /return '住宿时间'/)
assert.match(requestsComposableScript, /function buildTravelTimeLabelMap\(items, claim\)/)
assert.match(requestsComposableScript, /return claim\?\.expense_type === 'travel' \? '出行时间' : '业务发生时间'/)
assert.doesNotMatch(detailViewScript, /第 \$\{index \+ 1\} 项/)
})
@@ -1091,7 +1141,6 @@ test('expense detail table shows each item filled time from item creation time',
assert.match(detailViewScript, /function formatExpenseFilledTime\(value\)/)
assert.match(detailViewScript, /source\?\.filledAt[\s\S]*source\?\.created_at/)
assert.match(detailViewScript, /expenseTableColumnCount = computed\(\s*\(\) => 7 \+ \(isEditableRequest\.value \? 1 : 0\)/)
assert.match(requestsComposableScript, /filledAt: formatDateTime\(item\?\.created_at\) \|\| '待同步'/)
})
test('expense detail table has per-item risk explanation column', () => {
@@ -1132,7 +1181,6 @@ test('expense detail table has per-item risk explanation column', () => {
assert.match(detailViewScript, /itemNote: expenseEditor\.itemNote\.trim\(\)/)
assert.match(detailViewScript, /function hasExpenseRiskOrAbnormal\(item\)/)
assert.match(detailExpenseModelScript, /const itemNote = String\(source\?\.itemNote \?\? source\?\.item_note \?\? ''\)\.trim\(\)/)
assert.match(requestsComposableScript, /const itemNote = String\(item\?\.item_note \|\| item\?\.itemNote \|\| ''\)\.trim\(\)/)
})
test('expense detail shows standard-adjusted reimbursable amount separately from receipt amount', () => {
@@ -1147,8 +1195,6 @@ test('expense detail shows standard-adjusted reimbursable amount separately from
assert.doesNotMatch(detailViewScript, /filterSubmitterStandardAdjustedRiskCards/)
assert.match(detailViewScript, /acceptExpenseClaimStandardAdjustment/)
assert.match(detailExpenseModelScript, /STANDARD_ADJUSTMENT_RISK_SOURCE = 'reimbursement_standard_adjustment'/)
assert.match(requestsComposableScript, /STANDARD_ADJUSTMENT_RISK_SOURCE = 'reimbursement_standard_adjustment'/)
assert.match(requestsComposableScript, /const visibleExpenseAmount = expenseItems\.reduce[\s\S]*item\.reimbursableAmount/)
const riskFlags = [
{
@@ -1381,7 +1427,7 @@ test('detail smart entry confirms receipt upload before running recognition', ()
assert.match(detailViewTemplate, /@confirm="confirmSmartEntryUpload"/)
assert.match(detailViewScript, /const smartEntryUploadDialogOpen = ref\(false\)/)
assert.match(detailViewScript, /const smartEntryRecognitionBusy = ref\(false\)/)
assert.match(detailViewScript, /const actionBusy = computed\(\(\) =>[\s\S]*smartEntryRecognitionBusy\.value/)
assert.match(detailViewScript, /actionBusy = computed\(\(\) =>[\s\S]*smartEntryRecognitionBusy\.value/)
assert.match(detailViewScript, /const smartEntrySelectedFiles = ref\(\[\]\)/)
assert.match(detailViewScript, /function triggerSmartEntryUpload\(\)[\s\S]*smartEntryUploadDialogOpen\.value = true/)
assert.match(detailViewScript, /function handleSmartEntryFileChange\(event\)/)
@@ -1494,8 +1540,8 @@ test('travel detail AI advice uses material prompts only for required hotel rece
test('expense detail save is blocked while attachment recognition is running', () => {
assert.match(detailViewScript, /const uploadingExpenseId = ref\(''\)/)
assert.match(detailViewScript, /const actionBusy = computed\(\(\) =>[\s\S]*Boolean\(uploadingExpenseId\.value\)/)
assert.match(detailViewScript, /const canSubmit = computed\(\(\) => isEditableRequest\.value && !actionBusy\.value\)/)
assert.match(detailViewScript, /actionBusy = computed\(\(\) =>[\s\S]*Boolean\(uploadingExpenseId\.value\)/)
assert.match(detailViewScript, /const canSubmit = computed\(\(\) => isEditableRequest\.value && !getActionBusy\(\)\)/)
assert.match(detailViewScript, /if \(draftBlockingIssues\.value\.length\) \{[\s\S]*请先补全草稿信息,再提交审批。/)
assert.match(
detailViewTemplate,
@@ -1503,12 +1549,12 @@ test('expense detail save is blocked while attachment recognition is running', (
)
assert.match(
detailViewScript,
/if \(actionBusy\.value\) \{[\s\S]*toast\(uploadingExpenseId\.value \? '附件识别中,请等待识别完成后再保存。' : '当前操作处理中,请稍后再保存。'\)[\s\S]*return/
/if \(getActionBusy\(\)\) \{[\s\S]*toast\(uploadingExpenseId\.value \? '附件识别中,请等待识别完成后再保存。' : '当前操作处理中,请稍后再保存。'\)[\s\S]*return/
)
})
test('application detail uses application labels instead of reimbursement labels', () => {
assert.match(detailViewTemplate, /isApplicationDocument \? '申请进度'/)
assert.match(progressCardTemplate, /isApplicationDocument \? '申请进度'/)
assert.match(detailViewTemplate, /isApplicationDocument \? '申请详情' : '费用明细'/)
assert.match(detailViewTemplate, /展示本次申请的事实信息、职级规则测算和用户预估费用/)
assert.match(detailViewTemplate, /class="application-detail-facts"/)
@@ -1793,7 +1839,7 @@ test('transport ticket items no longer generate business location completion adv
assert.doesNotMatch(locationRequiredBlock, /'ride_ticket'/)
assert.match(
detailViewScript,
/const locationRequired = isLocationRequiredExpenseType\(item\.itemType\)[\s\S]*if \(locationRequired && isPlaceholderValue\(item\.itemLocation\)\) \{[\s\S]*issues\.push\('缺少地点'\)/
/const locationRequired = isLocationRequiredExpenseType\(item\.itemType\)[\s\S]*if \([^)]*locationRequired && isPlaceholderValue\(item\.itemLocation\)\) \{[\s\S]*issues\.push\('缺少地点'\)/
)
assert.doesNotMatch(detailViewScript, /完善第 1 条费用明细的业务地点/)
})

View File

@@ -7,7 +7,7 @@ const detailViewTemplate = readFileSync(
fileURLToPath(new URL('../src/views/TravelRequestDetailView.vue', import.meta.url)),
'utf8'
)
const detailViewScript = readFileSync(
const detailViewComponentScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/TravelRequestDetailView.js', import.meta.url)),
'utf8'
)
@@ -15,6 +15,40 @@ const detailExpenseModelScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelRequestDetailExpenseModel.js', import.meta.url)),
'utf8'
)
const detailViewSetupScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelRequestDetailSetup.js', import.meta.url)),
'utf8'
)
const detailSmartEntryScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelRequestDetailSmartEntryRecognition.js', import.meta.url)),
'utf8'
)
const detailAttachmentPreviewScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelRequestDetailAttachmentPreview.js', import.meta.url)),
'utf8'
)
const detailExpenseEditorScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelRequestDetailExpenseEditor.js', import.meta.url)),
'utf8'
)
const detailRiskSubmitScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelRequestDetailRiskSubmit.js', import.meta.url)),
'utf8'
)
const detailApprovalFlowScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelRequestDetailApprovalFlow.js', import.meta.url)),
'utf8'
)
const detailViewScript = [
detailViewComponentScript,
detailViewSetupScript,
detailSmartEntryScript,
detailAttachmentPreviewScript,
detailExpenseEditorScript,
detailRiskSubmitScript,
detailApprovalFlowScript,
detailExpenseModelScript
].join('\n')
const confirmDialogComponent = readFileSync(
fileURLToPath(new URL('../src/components/shared/ConfirmDialog.vue', import.meta.url)),
'utf8'
@@ -126,7 +160,7 @@ test('detail submit warns on missing risk explanation and supports standard adju
assert.match(runStandardAdjustmentRecalculation, /acceptExpenseClaimStandardAdjustment\(claimId, payload\)/)
assert.doesNotMatch(runStandardAdjustmentRecalculation, /submitConfirmDialogOpen\.value = true/)
assert.match(detailViewScript, /buildStandardAdjustmentPayloadModel\(\{[\s\S]*warnings:\s*submitRiskCards\.value/)
const actionBusyStart = detailViewScript.indexOf('const actionBusy = computed')
const actionBusyStart = detailViewScript.indexOf('actionBusy = computed')
const actionBusyEnd = detailViewScript.indexOf('const profile = computed', actionBusyStart)
assert.ok(actionBusyStart > -1 && actionBusyEnd > actionBusyStart)
assert.doesNotMatch(detailViewScript.slice(actionBusyStart, actionBusyEnd), /standardAdjustmentBusy/)
@@ -173,7 +207,7 @@ test('detail delete action allows admins or the applicant while the request is e
test('detail delete action does not allow in-progress applicant or claim manager fallback', () => {
const canDeleteStart = detailViewScript.indexOf('const canDeleteRequest = computed')
const canDeleteEnd = detailViewScript.indexOf('\n const isDirectManagerApprovalStage', canDeleteStart)
const canDeleteEnd = detailViewScript.indexOf('\n const isDirectManagerApprovalStage', canDeleteStart)
assert.ok(canDeleteStart >= 0)
assert.ok(canDeleteEnd > canDeleteStart)
const canDeleteBlock = detailViewScript.slice(canDeleteStart, canDeleteEnd)

View File

@@ -1,6 +1,6 @@
import assert from 'node:assert/strict'
import { execFileSync } from 'node:child_process'
import { readFileSync, statSync } from 'node:fs'
import { readdirSync, readFileSync, statSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
@@ -155,6 +155,14 @@ print(json.dumps({
const appShell = readSource('../src/views/AppShellRouteView.vue')
const workbenchView = readSource('../src/views/PersonalWorkbenchView.vue')
const aiMode = readSource('../src/components/business/PersonalWorkbenchAiMode.vue')
const aiModeTemplate = readSource('../src/components/business/PersonalWorkbenchAiMode.template.html')
const aiModeRuntimeDir = fileURLToPath(new URL('../src/composables/workbenchAiMode/', import.meta.url))
const aiModeRuntime = readdirSync(aiModeRuntimeDir)
.filter((file) => file.endsWith('.js'))
.sort()
.map((file) => readFileSync(new URL(`../src/composables/workbenchAiMode/${file}`, import.meta.url), 'utf8'))
.join('\n')
const aiModeSurface = `${aiMode}\n${aiModeTemplate}\n${aiModeRuntime}`
const aiModeStyles = readSource('../src/assets/styles/components/personal-workbench-ai-mode.css')
const workbenchViewStyles = readSource('../src/assets/styles/views/personal-workbench-view.css')
const appStyles = readSource('../src/assets/styles/app.css')
@@ -210,67 +218,106 @@ test('personal workbench view swaps the traditional dashboard with the AI mode s
})
test('AI mode screen follows the approved reference structure', () => {
assert.match(aiMode, /personal-workbench-ai-mode\.css/)
assert.doesNotMatch(aiMode, /workbench-ai-mode-robot-bg\.png/)
assert.match(aiMode, /workbench-ai-mode-orb-icon\.gif/)
assert.match(aiMode, /<img[\s\S]*class="workbench-ai-orb__image"/)
assert.match(aiMode, /小财管家/)
assert.match(aiMode, /我是您的小财管家/)
assert.match(aiMode, /今天我能帮您做点什么?/)
assert.match(aiMode, /费用测算中,请稍等/)
assert.match(aiMode, /rows="3"/)
assert.match(aiMode, /workbench-ai-composer-toolbar/)
assert.match(aiMode, /<article v-for="file in selectedFileCards"[\s\S]*class="workbench-ai-file-card"/)
assert.match(aiMode, /:aria-label="`移除附件 \$\{file\.name\}`"/)
assert.match(aiMode, /function removeAiModeFile\(fileKey\)/)
assert.match(aiMode, /const selectedFileCards = computed/)
assert.match(aiMode, /resolveAiComposerFileType\(file\)/)
assert.match(aiMode, /AI_COMPOSER_FILE_TYPE_META = \{[\s\S]*pdf:\s*\{ label:\s*'PDF'/)
assert.doesNotMatch(aiMode, /已选择 \{\{ selectedFiles\.length \}\} 份附件/)
assert.match(aiMode, /Axiom Ultra 3\.1/)
assert.match(aiMode, /mdi mdi-calendar-range/)
assert.match(aiMode, /workbench-ai-date-popover/)
assert.match(aiMode, /type="date"/)
assert.doesNotMatch(aiMode, /mdi mdi-web/)
assert.match(aiMode, /mdi mdi-microphone-outline/)
assert.match(aiMode, /mdi mdi-arrow-up/)
assert.match(aiMode, /快速开始/)
assert.match(aiMode, /action-icon-wrapper/)
assert.match(aiMode, /发起报销/)
assert.match(aiMode, /查询预算/)
assert.match(aiMode, /解释制度/)
assert.match(aiMode, /催办审批/)
assert.match(aiMode, /<Transition name="workbench-ai-panel-swap" mode="out-in" appear>/)
assert.match(aiMode, /@submit\.prevent="submitAiModePrompt"/)
assert.equal((aiMode.match(/type="submit"[\s\S]{0,160}class="workbench-ai-send-btn"/g) || []).length, 2)
assert.match(aiMode, /class="workbench-ai-conversation"/)
assert.match(aiMode, /class="workbench-ai-thread"[\s\S]*@scroll\.passive="handleInlineConversationScroll"/)
assert.match(aiMode, /workbench-ai-answer-card/)
assert.match(aiMode, /workbench-ai-answer-markdown/)
assert.match(aiMode, /v-html="renderInlineConversationHtml\(message\.content\)"/)
assert.match(aiMode, /workbench-ai-message-actions/)
assert.match(aiMode, /workbench-ai-conversation-actions/)
assert.match(aiMode, /scrollInlineConversationToTop/)
assert.match(aiMode, /requestDeleteCurrentConversation/)
assert.match(aiMode, /confirmDeleteConversation/)
assert.match(aiMode, /workbench-ai-confirm-dialog/)
assert.match(aiMode, /workbench-ai-thinking-toggle/)
assert.match(aiMode, /小财业务思考/)
assert.match(aiMode, /class="workbench-ai-thinking-expanded"/)
assert.match(aiMode, /class="workbench-ai-thinking-collapse-btn"/)
assert.match(aiMode, /class="workbench-ai-thinking-collapse-btn"[\s\S]*@click="toggleInlineThinking\(message\)"/)
assert.doesNotMatch(aiMode, /:disabled="message\.pending"/)
assert.match(aiMode, /isInlineThinkingExpanded/)
assert.match(aiMode, /toggleInlineThinking/)
assert.match(aiMode, /const thinkingCollapsedMessageIds = ref\(new Set\(\)\)/)
assert.match(aiMode, /thinkingCollapsedMessageIds\.value\.has\(message\.id\)/)
assert.match(aiMode, /nextCollapsedIds\.add\(message\.id\)/)
assert.match(aiMode, /nextCollapsedIds\.delete\(message\.id\)/)
assert.match(aiMode, /message\.pending && !hasInlineThinking\(message\)/)
assert.doesNotMatch(aiMode, /小财管家正在思考/)
assert.doesNotMatch(aiMode, /思考过程/)
assert.doesNotMatch(aiMode, /message\.pending \?/)
assert.match(aiMode, /继续和小财管家对话\.\.\./)
assert.match(aiModeSurface, /personal-workbench-ai-mode\.css/)
assert.doesNotMatch(aiModeSurface, /workbench-ai-mode-robot-bg\.png/)
assert.match(aiModeSurface, /workbench-ai-mode-orb-icon\.gif/)
assert.match(aiModeSurface, /<img[\s\S]*class="workbench-ai-orb__image"/)
assert.match(aiModeSurface, /小财管家/)
assert.match(aiModeSurface, /我是您的小财管家/)
assert.match(aiModeSurface, /今天我能帮您做点什么?/)
assert.match(aiModeSurface, /费用测算中,请稍等/)
assert.match(aiModeSurface, /rows="3"/)
assert.match(aiModeSurface, /workbench-ai-composer-toolbar/)
assert.match(aiModeSurface, /<article v-for="file in selectedFileCards"[\s\S]*class="workbench-ai-file-card"/)
assert.match(aiModeSurface, /:aria-label="`移除附件 \$\{file\.name\}`"/)
assert.match(aiModeSurface, /function removeAiModeFile\(fileKey\)/)
assert.match(aiModeSurface, /const selectedFileCards = computed/)
assert.match(aiModeSurface, /resolveAiComposerFileType\(file\)/)
assert.match(aiModeSurface, /AI_COMPOSER_FILE_TYPE_META = \{[\s\S]*pdf:\s*\{ label:\s*'PDF'/)
assert.match(aiModeSurface, /import \{ collectReceiptFiles \} from '\.\.\/\.\.\/views\/scripts\/travelReimbursementAttachmentModel\.js'/)
assert.match(aiModeSurface, /MAX_ATTACHMENTS,[\s\S]*mergeFilesWithLimit[\s\S]*travelReimbursementAttachmentModel\.js/)
assert.match(aiModeSurface, /import \* as aiAttachmentAssociationModel from '\.\.\/\.\.\/utils\/aiAttachmentAssociationModel\.js'/)
assert.match(aiModeSurface, /aiAttachmentAssociationModel\.resolveAiAttachmentAssociationMatch/)
assert.match(aiModeSurface, /aiAttachmentAssociationModel\.buildAiAttachmentAssociationResultMessage/)
assert.match(aiModeSurface, /syncExpenseClaimFilesToDraft/)
assert.match(aiModeSurface, /import \{ recognizeOcrFiles \} from '\.\.\/\.\.\/services\/ocr\.js'/)
assert.match(aiModeSurface, /const AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION = 'confirm_ai_attachment_association'/)
assert.match(aiModeSurface, /const AI_ATTACHMENT_OCR_DETAIL_ACTION = 'show_ai_attachment_ocr_details'/)
assert.match(aiModeSurface, /function isLikelyReceiptAssociationFile\(file = \{\}\)/)
assert.match(aiModeSurface, /function streamOrSetInlineAssistantContent\(messageId, content\)/)
assert.match(aiModeSurface, /ai-trusted-html:start/)
assert.match(aiModeSurface, /class="workbench-ai-ocr-detail-panel"/)
assert.match(aiModeSurface, /附件识别明细/)
assert.match(aiModeSurface, /attachmentOcrDetails: normalizeInlineAttachmentOcrDetails/)
assert.match(aiModeSurface, /function buildInlineAttachmentOcrDetails\(collected = \{\}, files = \[\]\)/)
assert.match(aiModeSurface, /function toggleInlineAttachmentOcrDetails\(message = \{\}, forceExpanded = null\)/)
assert.match(aiModeSurface, /function resolveLegacyAiAttachmentAssociationPayload\(content = ''\)/)
assert.match(aiModeSurface, /function hydrateInlineAttachmentAssociationSuggestedActions\(actions = \[\], content = ''\)/)
assert.match(aiModeSurface, /label:\s*'确认自动关联'/)
assert.match(aiModeSurface, /function shouldRunAiAttachmentAutoAssociation\(entry = \{\}, files = \[\], prompt = ''\)/)
assert.match(aiModeSurface, /files\.every\(\(file\) => isLikelyReceiptAssociationFile\(file\)\)/)
assert.match(aiModeSurface, /!shouldKeepAiAttachmentInAssistantReply\(prompt\)/)
assert.match(aiModeSurface, /const aiAttachmentAssociationRuntime = new Map\(\)/)
assert.match(aiModeSurface, /function findAiAttachmentAssociationRuntime\(options = \{\}\)/)
assert.match(aiModeSurface, /resolveAiAttachmentAssociationClaimNo\(actionPayload\)/)
assert.match(aiModeSurface, /if \(actionType === AI_ATTACHMENT_OCR_DETAIL_ACTION\)/)
assert.match(aiModeSurface, /collectReceiptFiles\(\{[\s\S]*files,[\s\S]*recognizeOcrFiles[\s\S]*\}\)/)
assert.match(aiModeSurface, /const claims = extractExpenseClaimItems\(claimsPayload\)/)
assert.match(aiModeSurface, /aiAttachmentAssociationModel\.resolveAiAttachmentAssociationMatch\(claims, collected\.ocrDocuments\)/)
assert.match(aiModeSurface, /aiAttachmentAssociationRuntime\.set\(associationId/)
assert.match(aiModeSurface, /attachmentOcrDetails,\s*[\s\S]*includeOcrDetails: Boolean\(attachmentOcrDetails\)/)
assert.match(aiModeSurface, /async function confirmAiAttachmentAssociation\(actionPayload = \{\}, sourceMessage = null\)/)
assert.match(aiModeSurface, /syncExpenseClaimFilesToDraft\(\{[\s\S]*fetchExpenseClaimDetail,[\s\S]*createExpenseClaimItem,[\s\S]*uploadExpenseClaimItemAttachment/)
assert.match(aiModeSurface, /if \(actionType === AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION\)/)
assert.match(aiModeSurface, /if \(shouldRunAiAttachmentAutoAssociation\(entry, files, cleanPrompt\)\) \{[\s\S]*requestAiAttachmentAssociationReply\(cleanPrompt, entry, files\)/)
assert.match(aiModeSurface, /const fileMergeResult = mergeFilesWithLimit\(selectedFiles\.value, Array\.from\(event\.target\.files \|\| \[\]\), MAX_ATTACHMENTS\)/)
assert.match(aiModeSurface, /selectedFiles\.value = fileMergeResult\.files/)
assert.doesNotMatch(aiModeSurface, /selectedFiles\.value = Array\.from\(event\.target\.files \|\| \[\]\)\.slice\(0, 10\)/)
assert.doesNotMatch(aiModeSurface, /已选择 \{\{ selectedFiles\.length \}\} 份附件/)
assert.match(aiModeSurface, /Axiom Ultra 3\.1/)
assert.match(aiModeSurface, /mdi mdi-calendar-range/)
assert.match(aiModeSurface, /workbench-ai-date-popover/)
assert.match(aiModeSurface, /type="date"/)
assert.doesNotMatch(aiModeSurface, /mdi mdi-web/)
assert.match(aiModeSurface, /mdi mdi-microphone-outline/)
assert.match(aiModeSurface, /mdi mdi-arrow-up/)
assert.match(aiModeSurface, /快速开始/)
assert.match(aiModeSurface, /action-icon-wrapper/)
assert.match(aiModeSurface, /发起报销/)
assert.match(aiModeSurface, /查询预算/)
assert.match(aiModeSurface, /解释制度/)
assert.match(aiModeSurface, /催办审批/)
assert.match(aiModeSurface, /<Transition name="workbench-ai-panel-swap" mode="out-in" appear>/)
assert.match(aiModeSurface, /@submit\.prevent="submitAiModePrompt"/)
assert.equal((aiModeSurface.match(/type="submit"[\s\S]{0,160}class="workbench-ai-send-btn"/g) || []).length, 2)
assert.match(aiModeSurface, /class="workbench-ai-conversation"/)
assert.match(aiModeSurface, /class="workbench-ai-thread"[\s\S]*@scroll\.passive="handleInlineConversationScroll"/)
assert.match(aiModeSurface, /workbench-ai-answer-card/)
assert.match(aiModeSurface, /workbench-ai-answer-markdown/)
assert.match(aiModeSurface, /v-html="renderInlineConversationHtml\(message\.content\)"/)
assert.match(aiModeSurface, /workbench-ai-message-actions/)
assert.match(aiModeSurface, /workbench-ai-conversation-actions/)
assert.match(aiModeSurface, /scrollInlineConversationToTop/)
assert.match(aiModeSurface, /requestDeleteCurrentConversation/)
assert.match(aiModeSurface, /confirmDeleteConversation/)
assert.match(aiModeSurface, /workbench-ai-confirm-dialog/)
assert.match(aiModeSurface, /workbench-ai-thinking-toggle/)
assert.match(aiModeSurface, /小财业务思考/)
assert.match(aiModeSurface, /class="workbench-ai-thinking-expanded"/)
assert.match(aiModeSurface, /class="workbench-ai-thinking-collapse-btn"/)
assert.match(aiModeSurface, /class="workbench-ai-thinking-collapse-btn"[\s\S]*@click="toggleInlineThinking\(message\)"/)
assert.doesNotMatch(aiModeSurface, /:disabled="message\.pending"/)
assert.match(aiModeSurface, /isInlineThinkingExpanded/)
assert.match(aiModeSurface, /toggleInlineThinking/)
assert.match(aiModeSurface, /const thinkingCollapsedMessageIds = ref\(new Set\(\)\)/)
assert.match(aiModeSurface, /thinkingCollapsedMessageIds\.value\.has\(message\.id\)/)
assert.match(aiModeSurface, /nextCollapsedIds\.add\(message\.id\)/)
assert.match(aiModeSurface, /nextCollapsedIds\.delete\(message\.id\)/)
assert.match(aiModeSurface, /message\.pending && !hasInlineThinking\(message\)/)
assert.doesNotMatch(aiModeSurface, /小财管家正在思考/)
assert.doesNotMatch(aiModeSurface, /思考过程/)
assert.doesNotMatch(aiModeSurface, /message\.pending \?/)
assert.match(aiModeSurface, /继续和小财管家对话\.\.\./)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-query-summary\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-query-summary__scope\)/)
@@ -295,40 +342,40 @@ test('AI mode screen follows the approved reference structure', () => {
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-action-link\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-table-wrap\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-image-frame\)/)
assert.match(aiMode, /import \{ fetchSettings \} from '\.\.\/\.\.\/services\/settings\.js'/)
assert.match(aiMode, /import \{ fetchStewardPlan, fetchStewardPlanStream \} from '\.\.\/\.\.\/services\/steward\.js'/)
assert.match(aiMode, /import \{ useWorkbenchComposerDate \} from '\.\.\/\.\.\/composables\/useWorkbenchComposerDate\.js'/)
assert.match(aiMode, /loadAiWorkbenchConversationHistory/)
assert.match(aiMode, /saveAiWorkbenchConversation/)
assert.match(aiMode, /deleteAiWorkbenchConversation/)
assert.match(aiMode, /import \{ renderAiConversationHtml \} from '\.\.\/\.\.\/utils\/aiConversationHtmlRenderer\.js'/)
assert.match(aiMode, /function renderInlineConversationHtml\(content\) \{[\s\S]*return renderAiConversationHtml\(content\)[\s\S]*\}/)
assert.doesNotMatch(aiMode, /import \{ renderMarkdown \} from '\.\.\/\.\.\/utils\/markdown\.js'/)
assert.match(aiMode, /buildStewardPlanRequest/)
assert.match(aiMode, /buildStewardPlanMessageText/)
assert.match(aiMode, /buildStewardSuggestedActions/)
assert.match(aiMode, /const emit = defineEmits\(\['conversation-change', 'conversation-history-change', 'open-document'\]\)/)
assert.match(aiMode, /function startInlineConversation\(prompt, entry = \{\}, files = \[\]\)/)
assert.match(aiMode, /activateInlineConversation\(\{[\s\S]*title:[\s\S]*\}\)[\s\S]*conversationMessages\.value\.push\(createInlineMessage\('user'/)
assert.match(aiMode, /persistCurrentConversation\(\)/)
assert.match(aiMode, /refreshConversationHistory\(\)/)
assert.match(aiMode, /fetchStewardPlanStream\(/)
assert.match(aiMode, /fetchStewardPlan\(/)
assert.match(aiMode, /const INLINE_ANSWER_STREAM_CHUNK_SIZE = 6/)
assert.match(aiMode, /function updateInlineMessageContent\(message, content\)/)
assert.match(aiMode, /async function streamInlineAssistantContent\(messageId, content\)/)
assert.match(aiMode, /const requiredApplicationContinuationFlow = resolveRequiredApplicationGateContinuationFlow\(normalizedPlan\)/)
assert.match(aiMode, /const finalMessageText = requiredApplicationContinuationFlow[\s\S]*buildAiRequiredApplicationGateAutoMessage\(normalizedPlan, requiredApplicationContinuationFlow\)[\s\S]*buildStewardPlanMessageText\(plan\)/)
assert.match(aiMode, /const hasServerStreamedContent = Boolean\(String\(pendingMessage\.content \|\| ''\)\.trim\(\)\)/)
assert.match(aiMode, /if \(!hasServerStreamedContent\) \{[\s\S]*await streamInlineAssistantContent\(pendingMessage\.id, finalMessageText\)[\s\S]*\}/)
assert.match(aiMode, /if \(actionType === AI_APPLICATION_ACTION_SUBMIT\) \{[\s\S]*buildInlineApplicationResultTable\(draftPayload/)
assert.match(aiMode, /需要查看完整详情时,请点击卡片“操作”行的“查看”进入单据详情。/)
assert.doesNotMatch(aiMode, /\*\*申请单号:\*\*/)
assert.doesNotMatch(aiMode, /createInlineMessage\('assistant', buildStewardPlanMessageText\(plan\)/)
assert.doesNotMatch(aiMode, /runOrchestrator\(/)
assert.doesNotMatch(aiMode, /buildFallbackAnswer/)
assert.doesNotMatch(aiMode, /已使用本地回复/)
assert.doesNotMatch(aiMode, /emit\('open-assistant'/)
assert.match(aiModeSurface, /import \{ fetchSettings \} from '\.\.\/\.\.\/services\/settings\.js'/)
assert.match(aiModeSurface, /fetchStewardPlan,[\s\S]*fetchStewardPlanStream[\s\S]*services\/steward\.js'/)
assert.match(aiModeSurface, /import \{ useWorkbenchComposerDate \} from '\.\.\/useWorkbenchComposerDate\.js'/)
assert.match(aiModeSurface, /loadAiWorkbenchConversationHistory/)
assert.match(aiModeSurface, /saveAiWorkbenchConversation/)
assert.match(aiModeSurface, /deleteAiWorkbenchConversation/)
assert.match(aiModeSurface, /import \{ renderAiConversationHtml \} from '\.\.\/\.\.\/utils\/aiConversationHtmlRenderer\.js'/)
assert.match(aiModeSurface, /function renderInlineConversationHtml\(content\) \{[\s\S]*return renderAiConversationHtml\(content\)[\s\S]*\}/)
assert.doesNotMatch(aiModeSurface, /import \{ renderMarkdown \} from '\.\.\/\.\.\/utils\/markdown\.js'/)
assert.match(aiModeSurface, /buildStewardPlanRequest/)
assert.match(aiModeSurface, /buildStewardPlanMessageText/)
assert.match(aiModeSurface, /buildStewardSuggestedActions/)
assert.match(aiModeSurface, /const emit = defineEmits\(\['conversation-change', 'conversation-history-change', 'open-document'\]\)/)
assert.match(aiModeSurface, /function startInlineConversation\(prompt, entry = \{\}, files = \[\]\)/)
assert.match(aiModeSurface, /activateInlineConversation\(\{[\s\S]*title:[\s\S]*\}\)[\s\S]*conversationMessages\.value\.push\(createInlineMessage\('user'/)
assert.match(aiModeSurface, /persistCurrentConversation\(\)/)
assert.match(aiModeSurface, /refreshConversationHistory\(\)/)
assert.match(aiModeSurface, /fetchStewardPlanStream\(/)
assert.match(aiModeSurface, /fetchStewardPlan\(/)
assert.match(aiModeSurface, /const INLINE_ANSWER_STREAM_CHUNK_SIZE = 6/)
assert.match(aiModeSurface, /function updateInlineMessageContent\(message, content\)/)
assert.match(aiModeSurface, /async function streamInlineAssistantContent\(messageId, content\)/)
assert.match(aiModeSurface, /const requiredApplicationContinuationFlow = resolveRequiredApplicationGateContinuationFlow\(normalizedPlan\)/)
assert.match(aiModeSurface, /const finalMessageText = requiredApplicationContinuationFlow[\s\S]*buildAiRequiredApplicationGateAutoMessage\(normalizedPlan, requiredApplicationContinuationFlow\)[\s\S]*buildStewardPlanMessageText\(plan\)/)
assert.match(aiModeSurface, /const hasServerStreamedContent = Boolean\(String\(pendingMessage\.content \|\| ''\)\.trim\(\)\)/)
assert.match(aiModeSurface, /if \(!hasServerStreamedContent\) \{[\s\S]*await streamInlineAssistantContent\(pendingMessage\.id, finalMessageText\)[\s\S]*\}/)
assert.match(aiModeSurface, /if \(actionType === AI_APPLICATION_ACTION_SUBMIT\) \{[\s\S]*buildInlineApplicationResultTable\(draftPayload/)
assert.match(aiModeSurface, /需要查看完整详情时,请点击卡片“操作”行的“查看”进入单据详情。/)
assert.doesNotMatch(aiModeSurface, /\*\*申请单号:\*\*/)
assert.doesNotMatch(aiModeSurface, /createInlineMessage\('assistant', buildStewardPlanMessageText\(plan\)/)
assert.doesNotMatch(aiModeSurface, /runOrchestrator\(/)
assert.doesNotMatch(aiModeSurface, /buildFallbackAnswer/)
assert.doesNotMatch(aiModeSurface, /已使用本地回复/)
assert.doesNotMatch(aiModeSurface, /emit\('open-assistant'/)
assert.match(aiModeStyles, /--ai-theme-rgb:\s*var\(--theme-primary-rgb/)
assert.match(aiModeStyles, /\.workbench-ai-mode\s*\{[\s\S]*min-height:\s*100%;[\s\S]*background:/)
assert.match(aiModeStyles, /\.workbench-ai-mode\.has-conversation\s*\{[\s\S]*place-items:\s*stretch;[\s\S]*padding:\s*0;/)
@@ -339,6 +386,11 @@ test('AI mode screen follows the approved reference structure', () => {
assert.match(fileCardRule, /border-radius:\s*16px;/)
assert.match(aiModeStyles, /\.workbench-ai-file-card__body strong,[\s\S]*\.workbench-ai-file-card__body small\s*\{[\s\S]*text-overflow:\s*ellipsis;/)
assert.match(aiModeStyles, /\.workbench-ai-file-card__remove\s*\{[\s\S]*border-radius:\s*999px;/)
assert.match(aiModeStyles, /\.ai-attachment-association-card/)
assert.match(aiModeStyles, /\.ai-ocr-recognition-card/)
assert.match(aiModeStyles, /\.ai-attachment-association__note/)
assert.match(aiModeStyles, /\.workbench-ai-ocr-detail-panel/)
assert.match(aiModeStyles, /\.workbench-ai-ocr-document__fields/)
assert.match(composerRule, /min-height:\s*154px;/)
assert.match(composerRule, /grid-template-rows:\s*minmax\(80px,\s*1fr\) auto;/)
assert.match(composerTextareaRule, /min-height:\s*80px;/)
@@ -365,23 +417,23 @@ test('AI mode screen follows the approved reference structure', () => {
assert.match(aiModeStyles, /\.workbench-ai-send-btn\s*\{[\s\S]*animation:\s*workbenchAiControlIn/)
assert.match(aiModeStyles, /\.workbench-ai-action:nth-child\(4\)\s*\{[\s\S]*animation-delay:\s*520ms;/)
assert.match(aiModeStyles, /\.workbench-ai-conversation\s*\{[\s\S]*grid-template-rows:\s*minmax\(0,\s*1fr\) auto;/)
assert.match(aiMode, /const inlineConversationAutoScrollPinned = ref\(true\)/)
assert.match(aiMode, /const INLINE_AUTO_SCROLL_THRESHOLD = 96/)
assert.match(aiMode, /const INLINE_LAYOUT_SETTLE_SCROLL_DELAY_MS = 260/)
assert.match(aiMode, /function isInlineConversationNearBottom\(\)/)
assert.match(aiMode, /function handleInlineConversationScroll\(\)\s*\{[\s\S]*inlineConversationAutoScrollPinned\.value = isInlineConversationNearBottom\(\)[\s\S]*\}/)
assert.match(aiMode, /function forceInlineConversationToBottom\(\)/)
assert.match(aiMode, /el\.scrollTop = el\.scrollHeight/)
assert.match(aiMode, /function scrollInlineConversationToBottom\(options = \{\}\)/)
assert.match(aiMode, /const shouldScroll = options\.force !== false/)
assert.match(aiMode, /if \(!shouldScroll\) \{[\s\S]*return[\s\S]*\}/)
assert.match(aiMode, /window\.requestAnimationFrame\(\(\) => \{[\s\S]*forceInlineConversationToBottom\(\)[\s\S]*\}\)/)
assert.match(aiMode, /window\.setTimeout\(\(\) => \{[\s\S]*if \(inlineConversationAutoScrollPinned\.value\) \{[\s\S]*forceInlineConversationToBottom\(\)[\s\S]*\}[\s\S]*\}, INLINE_LAYOUT_SETTLE_SCROLL_DELAY_MS\)/)
assert.match(aiMode, /const shouldAutoScroll = inlineConversationAutoScrollPinned\.value[\s\S]*updateInlineMessageContent\(message, streamedContent\)[\s\S]*scrollInlineConversationToBottom\(\{ force: shouldAutoScroll \}\)/)
assert.match(aiMode, /const shouldAutoScroll = inlineConversationAutoScrollPinned\.value[\s\S]*appendInlineMessageContent\(message, data\.delta \|\| data\.content \|\| data\.text \|\| ''\)[\s\S]*scrollInlineConversationToBottom\(\{ force: shouldAutoScroll \}\)/)
assert.match(aiMode, /inlineConversationAutoScrollPinned\.value = true[\s\S]*conversationMessages\.value\.push\(createInlineMessage\('user', cleanPrompt\)\)/)
assert.match(aiMode, /function openInlineRecentConversation\(item = \{\}\) \{[\s\S]*inlineConversationAutoScrollPinned\.value = true[\s\S]*conversationMessages\.value =/)
assert.doesNotMatch(aiMode, /scrollTo\(\{ top: el\.scrollHeight, behavior: 'smooth' \}\)/)
assert.match(aiModeSurface, /const inlineConversationAutoScrollPinned = ref\(true\)/)
assert.match(aiModeSurface, /const INLINE_AUTO_SCROLL_THRESHOLD = 96/)
assert.match(aiModeSurface, /const INLINE_LAYOUT_SETTLE_SCROLL_DELAY_MS = 260/)
assert.match(aiModeSurface, /function isInlineConversationNearBottom\(\)/)
assert.match(aiModeSurface, /function handleInlineConversationScroll\(\)\s*\{[\s\S]*inlineConversationAutoScrollPinned\.value = isInlineConversationNearBottom\(\)[\s\S]*\}/)
assert.match(aiModeSurface, /function forceInlineConversationToBottom\(\)/)
assert.match(aiModeSurface, /el\.scrollTop = el\.scrollHeight/)
assert.match(aiModeSurface, /function scrollInlineConversationToBottom\(options = \{\}\)/)
assert.match(aiModeSurface, /const shouldScroll = options\.force !== false/)
assert.match(aiModeSurface, /if \(!shouldScroll\) \{[\s\S]*return[\s\S]*\}/)
assert.match(aiModeSurface, /window\.requestAnimationFrame\(\(\) => \{[\s\S]*forceInlineConversationToBottom\(\)[\s\S]*\}\)/)
assert.match(aiModeSurface, /window\.setTimeout\(\(\) => \{[\s\S]*if \(inlineConversationAutoScrollPinned\.value\) \{[\s\S]*forceInlineConversationToBottom\(\)[\s\S]*\}[\s\S]*\}, INLINE_LAYOUT_SETTLE_SCROLL_DELAY_MS\)/)
assert.match(aiModeSurface, /const shouldAutoScroll = inlineConversationAutoScrollPinned\.value[\s\S]*updateInlineMessageContent\(message, streamedContent\)[\s\S]*scrollInlineConversationToBottom\(\{ force: shouldAutoScroll \}\)/)
assert.match(aiModeSurface, /const shouldAutoScroll = inlineConversationAutoScrollPinned\.value[\s\S]*appendInlineMessageContent\(message, data\.delta \|\| data\.content \|\| data\.text \|\| ''\)[\s\S]*scrollInlineConversationToBottom\(\{ force: shouldAutoScroll \}\)/)
assert.match(aiModeSurface, /inlineConversationAutoScrollPinned\.value = true[\s\S]*conversationMessages\.value\.push\(createInlineMessage\('user', cleanPrompt\)\)/)
assert.match(aiModeSurface, /function openInlineRecentConversation\(item = \{\}\) \{[\s\S]*inlineConversationAutoScrollPinned\.value = true[\s\S]*conversationMessages\.value =/)
assert.doesNotMatch(aiModeSurface, /scrollTo\(\{ top: el\.scrollHeight, behavior: 'smooth' \}\)/)
assert.match(aiModeStyles, /\.workbench-ai-thread\s*\{[\s\S]*display:\s*flex;[\s\S]*flex-direction:\s*column;[\s\S]*overflow-y:\s*auto;[\s\S]*scrollbar-width:\s*none;/)
assert.match(aiModeStyles, /\.workbench-ai-thread\s*>\s*:first-child\s*\{[\s\S]*margin-top:\s*auto;/)
assert.match(aiModeStyles, /\.workbench-ai-message\s*\{[\s\S]*flex:\s*0 0 auto;/)
@@ -436,3 +488,15 @@ test('AI mode screen follows the approved reference structure', () => {
assert.ok(pngPresentation.minimumForegroundWidthRatio > 0.9)
assert.ok(pngPresentation.minimumForegroundHeightRatio > 0.9)
})
test('AI mode normal assistant requests include OCR context for uploaded receipts', () => {
assert.match(aiModeSurface, /function isLikelyAiModeOcrFile\(file = \{\}\)/)
assert.match(aiModeSurface, /async function collectAiModeReceiptContext\(files = \[\]\)/)
assert.match(aiModeSurface, /collectReceiptFiles\(\{[\s\S]*files:\s*ocrFiles,[\s\S]*recognizeOcrFiles[\s\S]*\}\)/)
assert.match(aiModeSurface, /const receiptContext = await collectAiModeReceiptContext\(files\)/)
assert.match(aiModeSurface, /ocr_summary:\s*receiptContext\.ocrSummary/)
assert.match(aiModeSurface, /ocr_documents:\s*receiptContext\.ocrDocuments/)
assert.match(aiModeSurface, /attachment_names:\s*receiptContext\.attachmentNames/)
assert.match(aiModeSurface, /attachment_count:\s*receiptContext\.attachmentCount/)
assert.match(aiModeSurface, /ocr_source_file_names:\s*receiptContext\.ocrSourceFileNames/)
})

View File

@@ -1,5 +1,5 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { readdirSync, readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
@@ -19,6 +19,17 @@ const aiMode = readFileSync(
fileURLToPath(new URL('../src/components/business/PersonalWorkbenchAiMode.vue', import.meta.url)),
'utf8'
)
const aiModeTemplate = readFileSync(
fileURLToPath(new URL('../src/components/business/PersonalWorkbenchAiMode.template.html', import.meta.url)),
'utf8'
)
const aiModeRuntimeDir = fileURLToPath(new URL('../src/composables/workbenchAiMode/', import.meta.url))
const aiModeRuntime = readdirSync(aiModeRuntimeDir)
.filter((file) => file.endsWith('.js'))
.sort()
.map((file) => readFileSync(new URL(`../src/composables/workbenchAiMode/${file}`, import.meta.url), 'utf8'))
.join('\n')
const aiModeSurface = `${aiMode}\n${aiModeTemplate}\n${aiModeRuntime}`
const aiDetailReference = readFileSync(
fileURLToPath(new URL('../src/utils/aiDocumentDetailReference.js', import.meta.url)),
'utf8'
@@ -28,21 +39,43 @@ test('workbench document detail keeps workbench as the return target', () => {
assert.match(workbench, /source:\s*'workbench'/)
assert.match(workbench, /returnTo:\s*'workbench'/)
assert.match(appShell, /:back-label="detailBackLabel"/)
assert.match(appShell, /String\(payload\.returnTo \|\| ''\)\.trim\(\) === 'workbench'/)
assert.match(appShell, /const explicitReturnTo = resolveDocumentDetailReturnTarget\(payload\.returnTo\)/)
assert.match(appShell, /const fallbackToWorkbench = \(/)
assert.match(appShell, /String\(payload\.source \|\| ''\)\.trim\(\) === 'workbench'/)
assert.match(appShell, /const returnTo = explicitReturnTo \|\| \(fallbackToWorkbench \? 'workbench' : ''\)/)
assert.match(appShell, /const detailPayload = request \|\| \{[\s\S]*detailLookupOnly:\s*true[\s\S]*\}/)
assert.match(appShell, /openRequestDetail\(detailPayload,\s*\{ returnTo \}\)/)
assert.match(appShellComposable, /const detailReturnTarget = computed/)
assert.match(appShellComposable, /detailReturnTarget\.value === 'workbench' \? '返回首页' : '返回单据中心'/)
assert.match(appShellComposable, /nextQuery\.returnTo = 'workbench'/)
assert.match(appShellComposable, /const returnTo = resolveDocumentDetailReturnTarget\(options\.returnTo\)/)
assert.match(appShellComposable, /nextQuery\.returnTo = returnTo/)
assert.match(appShellComposable, /router\.push\(\{ name: 'app-workbench' \}\)/)
assert.match(appShellComposable, /router\.push\(\{ name: 'app-documents', query: buildDocumentReturnQuery\(\) \}\)/)
})
test('AI conversation document detail returns to the active conversation content', () => {
assert.match(aiDetailReference, /source:\s*'ai-conversation'/)
assert.match(aiDetailReference, /returnTo:\s*'conversation'/)
assert.match(appShell, /@back-to-requests="handleDocumentDetailBack"/)
assert.match(appShell, /const DOCUMENT_DETAIL_RETURN_TARGETS = new Set\(\['workbench', 'conversation'\]\)/)
assert.match(
appShell,
/function handleDocumentDetailBack\(\) \{[\s\S]*detailReturnTarget\.value === 'conversation'[\s\S]*dispatchAiSidebarCommand\('open-recent', activeConversation\)/
)
assert.match(
appShellComposable,
/if \(detailReturnTarget\.value === 'conversation'\) \{[\s\S]*return '返回对话'/
)
assert.match(
appShellComposable,
/if \(detailReturnTarget\.value === 'conversation'\) \{[\s\S]*return router\.push\(\{ name: 'app-workbench' \}\)/
)
})
test('AI detail links resolve real claim identity before opening document detail', () => {
assert.match(aiMode, /buildAiDocumentDetailRequest/)
assert.match(aiMode, /parseAiApplicationDetailHref/)
assert.match(aiMode, /parseAiDocumentDetailHref/)
assert.match(aiModeSurface, /buildAiDocumentDetailRequest/)
assert.match(aiModeSurface, /parseAiApplicationDetailHref/)
assert.match(aiModeSurface, /parseAiDocumentDetailHref/)
assert.match(aiDetailReference, /detailLookupOnly:\s*true/)
assert.match(aiDetailReference, /params\.get\('claim_id'\)/)
assert.match(aiDetailReference, /params\.get\('claim_no'\)/)