refactor: enforce 800 line source limits
This commit is contained in:
281
web/tests/ai-attachment-association-model.test.mjs
Normal file
281
web/tests/ai-attachment-association-model.test.mjs
Normal 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 武汉-上海.pdf:G458 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/)
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
79
web/tests/code-size-limits.test.mjs
Normal file
79
web/tests/code-size-limits.test.mjs
Normal 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')
|
||||
}`
|
||||
)
|
||||
})
|
||||
@@ -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"/)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
54
web/tests/expense-claim-attachment-sync.test.mjs
Normal file
54
web/tests/expense-claim-attachment-sync.test.mjs
Normal 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' }
|
||||
])
|
||||
})
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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/)
|
||||
|
||||
@@ -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/)
|
||||
|
||||
@@ -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/)
|
||||
|
||||
@@ -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/)
|
||||
|
||||
@@ -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 条费用明细的业务地点/)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
|
||||
@@ -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'\)/)
|
||||
|
||||
Reference in New Issue
Block a user