2026-05-20 21:00:47 +08:00
|
|
|
|
import assert from 'node:assert/strict'
|
|
|
|
|
|
import { readFileSync } from 'node:fs'
|
|
|
|
|
|
import test from 'node:test'
|
|
|
|
|
|
import { fileURLToPath } from 'node:url'
|
2026-05-30 15:46:51 +08:00
|
|
|
|
import { ref } from 'vue'
|
2026-05-20 21:00:47 +08:00
|
|
|
|
|
2026-05-22 23:47:28 +08:00
|
|
|
|
import {
|
2026-06-03 15:46:56 +08:00
|
|
|
|
buildReviewFormContextFromPayload,
|
2026-05-22 23:47:28 +08:00
|
|
|
|
buildLocallySyncedReviewPayload,
|
|
|
|
|
|
buildReviewNextStepRichCopy,
|
|
|
|
|
|
buildReviewPlainFollowupCopy,
|
|
|
|
|
|
isTravelReviewPayload,
|
|
|
|
|
|
resolveReviewFooterActions
|
|
|
|
|
|
} from '../src/views/scripts/travelReimbursementReviewModel.js'
|
2026-05-30 15:46:51 +08:00
|
|
|
|
import { useTravelReimbursementAttachments } from '../src/views/scripts/useTravelReimbursementAttachments.js'
|
2026-05-22 23:47:28 +08:00
|
|
|
|
import { renderMarkdown } from '../src/utils/markdown.js'
|
2026-05-22 16:00:19 +08:00
|
|
|
|
|
2026-05-20 21:00:47 +08:00
|
|
|
|
const createViewTemplate = readFileSync(
|
|
|
|
|
|
fileURLToPath(new URL('../src/views/TravelReimbursementCreateView.vue', import.meta.url)),
|
|
|
|
|
|
'utf8'
|
|
|
|
|
|
)
|
|
|
|
|
|
const createViewScript = readFileSync(
|
|
|
|
|
|
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
|
|
|
|
|
|
'utf8'
|
|
|
|
|
|
)
|
2026-06-02 14:01:51 +08:00
|
|
|
|
const messageItemTemplate = readFileSync(
|
|
|
|
|
|
fileURLToPath(new URL('../src/components/travel/TravelReimbursementMessageItem.vue', import.meta.url)),
|
|
|
|
|
|
'utf8'
|
|
|
|
|
|
)
|
|
|
|
|
|
const insightPanelTemplate = readFileSync(
|
|
|
|
|
|
fileURLToPath(new URL('../src/components/travel/TravelReimbursementInsightPanel.vue', import.meta.url)),
|
|
|
|
|
|
'utf8'
|
|
|
|
|
|
)
|
2026-05-21 09:28:33 +08:00
|
|
|
|
const reimbursementService = readFileSync(
|
|
|
|
|
|
fileURLToPath(new URL('../src/services/reimbursements.js', import.meta.url)),
|
|
|
|
|
|
'utf8'
|
|
|
|
|
|
)
|
2026-06-02 14:01:51 +08:00
|
|
|
|
const reimbursementFlowScript = readFileSync(
|
|
|
|
|
|
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementFlow.js', import.meta.url)),
|
|
|
|
|
|
'utf8'
|
|
|
|
|
|
)
|
2026-05-21 23:53:03 +08:00
|
|
|
|
const reviewActionsScript = readFileSync(
|
|
|
|
|
|
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementReviewActions.js', import.meta.url)),
|
|
|
|
|
|
'utf8'
|
|
|
|
|
|
)
|
2026-05-22 16:00:19 +08:00
|
|
|
|
const reviewDrawerScript = readFileSync(
|
|
|
|
|
|
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementReviewDrawer.js', import.meta.url)),
|
|
|
|
|
|
'utf8'
|
|
|
|
|
|
)
|
2026-05-21 23:53:03 +08:00
|
|
|
|
const submitComposerScript = readFileSync(
|
|
|
|
|
|
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
|
|
|
|
|
|
'utf8'
|
|
|
|
|
|
)
|
|
|
|
|
|
const attachmentsScript = readFileSync(
|
|
|
|
|
|
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementAttachments.js', import.meta.url)),
|
|
|
|
|
|
'utf8'
|
|
|
|
|
|
)
|
2026-05-22 23:47:28 +08:00
|
|
|
|
const sessionStateScript = readFileSync(
|
|
|
|
|
|
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSessionState.js', import.meta.url)),
|
|
|
|
|
|
'utf8'
|
|
|
|
|
|
)
|
|
|
|
|
|
const createViewBaseStyles = readFileSync(
|
|
|
|
|
|
fileURLToPath(new URL('../src/assets/styles/views/travel-reimbursement-create-view.css', import.meta.url)),
|
|
|
|
|
|
'utf8'
|
|
|
|
|
|
)
|
|
|
|
|
|
const createViewPart2Styles = readFileSync(
|
|
|
|
|
|
fileURLToPath(new URL('../src/assets/styles/views/travel-reimbursement-create-view-part2.css', import.meta.url)),
|
|
|
|
|
|
'utf8'
|
|
|
|
|
|
)
|
|
|
|
|
|
const createViewPart3Styles = readFileSync(
|
|
|
|
|
|
fileURLToPath(new URL('../src/assets/styles/views/travel-reimbursement-create-view-part3.css', import.meta.url)),
|
|
|
|
|
|
'utf8'
|
|
|
|
|
|
)
|
|
|
|
|
|
const createViewPart4Styles = readFileSync(
|
|
|
|
|
|
fileURLToPath(new URL('../src/assets/styles/views/travel-reimbursement-create-view-part4.css', import.meta.url)),
|
|
|
|
|
|
'utf8'
|
|
|
|
|
|
)
|
2026-06-02 14:01:51 +08:00
|
|
|
|
const insightPanelStyles = readFileSync(
|
|
|
|
|
|
fileURLToPath(new URL('../src/assets/styles/components/travel-reimbursement-insight-panel.css', import.meta.url)),
|
|
|
|
|
|
'utf8'
|
|
|
|
|
|
)
|
2026-05-20 21:00:47 +08:00
|
|
|
|
|
|
|
|
|
|
test('review drawer tools expose the default review tab before conditional document and risk tabs', () => {
|
2026-05-22 16:00:19 +08:00
|
|
|
|
assert.match(createViewTemplate, /v-if="activeReviewPayload && reviewOverviewDrawerAvailable"[\s\S]*title="报销识别核对"[\s\S]*@click="switchToReviewOverviewDrawer"/)
|
2026-05-20 21:00:47 +08:00
|
|
|
|
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.ok(
|
|
|
|
|
|
createViewTemplate.indexOf('title="报销识别核对"') < createViewTemplate.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/)
|
|
|
|
|
|
})
|
2026-05-21 09:28:33 +08:00
|
|
|
|
|
2026-05-22 23:47:28 +08:00
|
|
|
|
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(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;/)
|
|
|
|
|
|
assert.match(createViewPart2Styles, /\.review-document-stage\s*\{[\s\S]*grid-template-rows:\s*auto auto minmax\(0,\s*1fr\);/)
|
|
|
|
|
|
assert.match(createViewPart2Styles, /\.review-document-scroll\s*\{[\s\S]*max-height:\s*none;[\s\S]*min-height:\s*0;/)
|
|
|
|
|
|
assert.match(createViewPart2Styles, /\.review-document-preview-card\.image\s*\{[\s\S]*place-items:\s*center;[\s\S]*min-height:\s*220px;/)
|
|
|
|
|
|
assert.match(createViewPart2Styles, /\.review-document-preview-card\.image img\s*\{[\s\S]*height:\s*auto;[\s\S]*object-fit:\s*contain;/)
|
|
|
|
|
|
assert.doesNotMatch(createViewPart2Styles, /\.review-document-preview-card\.image img\s*\{[\s\S]*object-fit:\s*cover;/)
|
|
|
|
|
|
assert.match(createViewPart4Styles, /\.review-overlay\s*\{[\s\S]*align-items:\s*center;[\s\S]*justify-content:\s*center;/)
|
|
|
|
|
|
assert.match(createViewPart4Styles, /\.review-preview-modal\s*\{[\s\S]*margin:\s*auto;[\s\S]*flex:\s*none;/)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-06-06 17:19:07 +08:00
|
|
|
|
test('assistant conversation keeps composer visible when generated cards grow tall', () => {
|
|
|
|
|
|
assert.match(createViewBaseStyles, /\.assistant-layout\s*\{[\s\S]*height:\s*100%;[\s\S]*max-height:\s*100%;[\s\S]*overflow:\s*hidden;/)
|
|
|
|
|
|
assert.match(
|
|
|
|
|
|
createViewBaseStyles,
|
|
|
|
|
|
/\.dialog-panel\s*\{[\s\S]*display:\s*flex;[\s\S]*flex-direction:\s*column;[\s\S]*height:\s*100%;[\s\S]*max-height:\s*100%;[\s\S]*min-height:\s*0;[\s\S]*overflow:\s*hidden;/
|
|
|
|
|
|
)
|
|
|
|
|
|
assert.match(createViewBaseStyles, /\.dialog-toolbar\s*\{[\s\S]*flex:\s*0 0 auto;/)
|
|
|
|
|
|
assert.match(
|
|
|
|
|
|
createViewBaseStyles,
|
|
|
|
|
|
/\.message-list\s*\{[\s\S]*flex:\s*1 1 0;[\s\S]*min-height:\s*0;[\s\S]*max-height:\s*100%;[\s\S]*overflow-y:\s*auto;[\s\S]*overscroll-behavior:\s*contain;/
|
|
|
|
|
|
)
|
|
|
|
|
|
assert.match(createViewBaseStyles, /\.composer\s*\{[\s\S]*position:\s*sticky;[\s\S]*bottom:\s*0;[\s\S]*flex:\s*0 0 auto;[\s\S]*flex-shrink:\s*0;/)
|
|
|
|
|
|
assert.match(createViewPart4Styles, /@media \(max-width:\s*1440px\)[\s\S]*\.dialog-panel\s*\{[\s\S]*flex:\s*1 1 0;[\s\S]*height:\s*auto;[\s\S]*max-height:\s*100%;/)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-06-02 14:01:51 +08:00
|
|
|
|
test('document review OCR result card header keeps copy and navigation separated', () => {
|
|
|
|
|
|
assert.match(insightPanelTemplate, /class="review-side-head-copy"[\s\S]*票据识别结果卡片[\s\S]*逐张查看 OCR 结果/)
|
|
|
|
|
|
assert.match(insightPanelStyles, /\.review-document-switch-head\s*\{[\s\S]*display:\s*grid;[\s\S]*grid-template-columns:\s*minmax\(0,\s*1fr\) auto;/)
|
|
|
|
|
|
assert.match(insightPanelStyles, /\.review-side-head-copy\s*\{[\s\S]*min-width:\s*0;[\s\S]*display:\s*grid;/)
|
|
|
|
|
|
assert.match(insightPanelStyles, /\.review-side-head-copy p\s*\{[\s\S]*overflow-wrap:\s*anywhere;/)
|
|
|
|
|
|
assert.match(insightPanelStyles, /\.review-document-nav\s*\{[\s\S]*flex:\s*0 0 auto;[\s\S]*flex-wrap:\s*nowrap;[\s\S]*white-space:\s*nowrap;/)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-22 23:47:28 +08:00
|
|
|
|
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(reviewDrawerScript, /renderKey:\s*''/)
|
|
|
|
|
|
assert.match(reviewDrawerScript, /renderKey:\s*\[[\s\S]*Date\.now\(\)[\s\S]*\]\.join\('__'\)/)
|
|
|
|
|
|
assert.match(attachmentsScript, /isTemporaryPreviewUrl/)
|
|
|
|
|
|
assert.match(attachmentsScript, /existingPreview\?\.url && !isTemporaryPreviewUrl\(existingPreview\.url\)/)
|
|
|
|
|
|
assert.match(sessionStateScript, /filterPersistableFilePreviews\(state\.reviewFilePreviews\)/)
|
|
|
|
|
|
assert.doesNotMatch(sessionStateScript, /filterPersistableFilePreviews\(nextState\.reviewFilePreviews\)/)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('local transport review no longer uses the travel hotel template', () => {
|
|
|
|
|
|
const reviewPayload = {
|
|
|
|
|
|
slot_cards: [
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'expense_type',
|
|
|
|
|
|
label: '报销类型',
|
|
|
|
|
|
value: '交通费',
|
|
|
|
|
|
normalized_value: 'transport',
|
|
|
|
|
|
status: 'identified'
|
|
|
|
|
|
}
|
|
|
|
|
|
],
|
|
|
|
|
|
document_cards: [
|
|
|
|
|
|
{
|
|
|
|
|
|
document_type: 'taxi_receipt',
|
|
|
|
|
|
suggested_expense_type: 'transport',
|
|
|
|
|
|
scene_label: '交通费'
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
assert.equal(isTravelReviewPayload(reviewPayload, { expense_type: '交通费' }), false)
|
|
|
|
|
|
assert.match(createViewScript, /shouldShowReviewFactCard\(reviewPayload, 'customer_name'/)
|
|
|
|
|
|
assert.doesNotMatch(
|
|
|
|
|
|
createViewScript,
|
|
|
|
|
|
/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}例如:业务招待费 \/ 差旅费/)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('local save of changed reimbursement category updates edit fields too', () => {
|
|
|
|
|
|
const nextPayload = buildLocallySyncedReviewPayload(
|
|
|
|
|
|
{
|
|
|
|
|
|
can_proceed: false,
|
|
|
|
|
|
edit_fields: [
|
|
|
|
|
|
{ key: 'expense_type', label: '报销分类', value: '交通费' },
|
|
|
|
|
|
{ key: 'reason', label: '事由', value: '打车去客户现场' }
|
|
|
|
|
|
],
|
|
|
|
|
|
slot_cards: [
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'expense_type',
|
|
|
|
|
|
label: '报销类型',
|
|
|
|
|
|
value: '交通费',
|
|
|
|
|
|
normalized_value: 'transport',
|
|
|
|
|
|
required: true,
|
|
|
|
|
|
status: 'identified'
|
|
|
|
|
|
}
|
|
|
|
|
|
],
|
|
|
|
|
|
confirmation_actions: []
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
expense_type: '办公用品费',
|
|
|
|
|
|
reason_value: '右侧核对后改为办公用品费'
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const expenseTypeField = nextPayload.edit_fields.find((item) => item.key === 'expense_type')
|
|
|
|
|
|
assert.equal(expenseTypeField.value, '办公用品费')
|
|
|
|
|
|
assert.equal(nextPayload.slot_cards[0].value, '办公用品费')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('next step action uses rich text guidance and confirm dialog instead of footer button', () => {
|
|
|
|
|
|
const reviewPayload = {
|
|
|
|
|
|
can_proceed: true,
|
|
|
|
|
|
risk_briefs: [
|
|
|
|
|
|
{ level: 'low', title: '票据提示', content: '普通提示' }
|
|
|
|
|
|
],
|
|
|
|
|
|
confirmation_actions: [
|
|
|
|
|
|
{ action_type: 'save_draft', label: '保存为草稿' },
|
|
|
|
|
|
{ action_type: 'next_step', label: '继续下一步', emphasis: 'primary' }
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
2026-05-26 09:15:14 +08:00
|
|
|
|
const copy = buildReviewNextStepRichCopy(reviewPayload, { detailHref: '/app/documents/claim-1' })
|
2026-05-22 23:47:28 +08:00
|
|
|
|
const rendered = renderMarkdown(copy)
|
|
|
|
|
|
|
|
|
|
|
|
assert.match(copy, /系统识别您的单据已经填写完所有已知信息/)
|
|
|
|
|
|
assert.match(copy, /现存在 1 条低风险,0 条中风险,0 条高风险/)
|
|
|
|
|
|
assert.doesNotMatch(copy, /#review-risk-low/)
|
|
|
|
|
|
assert.doesNotMatch(copy, /#review-risk-medium/)
|
|
|
|
|
|
assert.doesNotMatch(copy, /#review-risk-high/)
|
|
|
|
|
|
assert.match(copy, /\[右侧\]\(#review-risk-panel\) 风险信息提示窗/)
|
|
|
|
|
|
assert.match(copy, /\[继续下一步\]\(#review-next-step\)/)
|
2026-05-26 09:15:14 +08:00
|
|
|
|
assert.match(copy, /\[快速修改单据信息\]\(\/app\/documents\/claim-1\)/)
|
2026-05-22 23:47:28 +08:00
|
|
|
|
assert.doesNotMatch(rendered, /markdown-risk-link-/)
|
|
|
|
|
|
assert.match(rendered, /<span class="markdown-risk-text-low">低风险<\/span>/)
|
|
|
|
|
|
assert.match(rendered, /<span class="markdown-risk-text-medium">中风险<\/span>/)
|
|
|
|
|
|
assert.match(rendered, /<span class="markdown-risk-text-high">高风险<\/span>/)
|
|
|
|
|
|
assert.doesNotMatch(rendered, /href="#review-risk-low"/)
|
|
|
|
|
|
assert.doesNotMatch(rendered, /href="#review-risk-medium"/)
|
|
|
|
|
|
assert.doesNotMatch(rendered, /href="#review-risk-high"/)
|
|
|
|
|
|
assert.match(rendered, /markdown-action-link-risk/)
|
|
|
|
|
|
assert.match(rendered, /markdown-action-link-next/)
|
|
|
|
|
|
assert.deepEqual(resolveReviewFooterActions(reviewPayload), [])
|
|
|
|
|
|
|
|
|
|
|
|
const highRiskCopy = buildReviewNextStepRichCopy(
|
|
|
|
|
|
{
|
|
|
|
|
|
...reviewPayload,
|
|
|
|
|
|
risk_briefs: [{ level: 'high', title: '金额超标' }]
|
|
|
|
|
|
},
|
2026-05-26 09:15:14 +08:00
|
|
|
|
{ detailHref: '/app/documents/claim-1' }
|
2026-05-22 23:47:28 +08:00
|
|
|
|
)
|
|
|
|
|
|
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(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,/)
|
|
|
|
|
|
assert.doesNotMatch(createViewBaseStyles, /markdown-risk-link-low/)
|
|
|
|
|
|
assert.match(createViewBaseStyles, /\.markdown-risk-text-low[\s\S]*color:\s*#2563eb/)
|
|
|
|
|
|
assert.match(createViewBaseStyles, /\.markdown-risk-text-medium[\s\S]*color:\s*#d97706/)
|
|
|
|
|
|
assert.match(createViewBaseStyles, /\.markdown-risk-text-high[\s\S]*color:\s*#dc2626/)
|
|
|
|
|
|
assert.match(createViewPart3Styles, /\.review-next-step-rich-copy\s*\{[\s\S]*margin-top:\s*30px;/)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-21 09:28:33 +08:00
|
|
|
|
test('review risk drawer lists risk briefs without score and posts details into the conversation', () => {
|
|
|
|
|
|
const riskItemsBlock = createViewScript.match(/function buildReviewRiskItems\(reviewPayload\) \{[\s\S]*?\n\}\n\nfunction 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(riskItemsBlock[0], /\.slice\(0,\s*6\)/)
|
|
|
|
|
|
assert.match(createViewScript, /const DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS = \[[\s\S]*'历史报销画像'[\s\S]*'制度注意事项'/)
|
|
|
|
|
|
assert.match(
|
|
|
|
|
|
createViewScript,
|
|
|
|
|
|
/function resolveReviewRiskBriefs\(reviewPayload\) \{[\s\S]*DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS\.some/
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert.match(
|
|
|
|
|
|
createViewTemplate,
|
|
|
|
|
|
/class="review-side-risk-item"[\s\S]*@click="appendReviewRiskBriefToConversation\(item\)"/
|
|
|
|
|
|
)
|
|
|
|
|
|
assert.doesNotMatch(createViewTemplate, /\{\{\s*item\.levelLabel\s*\}\}/)
|
|
|
|
|
|
assert.match(createViewTemplate, /class="review-side-risk-icon" :title="item\.levelLabel"/)
|
2026-05-22 23:47:28 +08:00
|
|
|
|
assert.match(createViewScript, /info:\s*\{[\s\S]*label:\s*'提示'/)
|
2026-05-21 09:28:33 +08:00
|
|
|
|
assert.match(createViewScript, /medium:\s*\{[\s\S]*label:\s*'中风险'/)
|
|
|
|
|
|
assert.match(createViewScript, /low:\s*\{[\s\S]*label:\s*'低风险'/)
|
2026-05-22 23:47:28 +08:00
|
|
|
|
assert.match(createViewScript, /const isInfo = String\(item\?\.level \|\| ''\)\.trim\(\) === 'info'/)
|
|
|
|
|
|
assert.match(createViewScript, /\$\{isInfo \? '提示内容' : '风险点'\}:\$\{summary\}/)
|
|
|
|
|
|
assert.match(createViewScript, /\$\{isInfo \? '处理建议' : '修改建议'\}:\$\{suggestion\}/)
|
2026-05-21 09:28:33 +08:00
|
|
|
|
assert.match(createViewScript, /function normalizeReviewRiskTitle/)
|
|
|
|
|
|
assert.match(createViewScript, /\.replace\(\/AI\\s\*预审/)
|
|
|
|
|
|
assert.match(createViewScript, /\.replace\(\/\(高风险\|中风险\|低风险\)\/g,\s*''\)/)
|
|
|
|
|
|
assert.match(createViewScript, /sourceLabel:\s*meta\.label/)
|
|
|
|
|
|
assert.doesNotMatch(createViewScript, /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(
|
|
|
|
|
|
createViewScript,
|
|
|
|
|
|
/function appendReviewRiskBriefToConversation\(item\) \{[\s\S]*messages\.value\.push\(createMessage\('assistant'/
|
|
|
|
|
|
)
|
2026-05-22 16:00:19 +08:00
|
|
|
|
assert.match(createViewScript, /function buildReviewRiskConversationText\(item, detailTarget = \{\}\)/)
|
2026-05-26 09:15:14 +08:00
|
|
|
|
assert.match(createViewScript, /function resolveReviewDetailTarget\(message = null\) \{[\s\S]*router\.resolve\(\{[\s\S]*name: 'app-document-detail'/)
|
2026-05-22 23:47:28 +08:00
|
|
|
|
assert.match(createViewScript, /function resolveReviewRiskDetailTarget\(\) \{[\s\S]*return resolveReviewDetailTarget\(\)/)
|
2026-05-22 16:00:19 +08:00
|
|
|
|
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\} 详情重新填写/)
|
2026-05-21 09:28:33 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-22 16:00:19 +08:00
|
|
|
|
test('review drawer default mode is scoped by the current action and travel overview uses travel-specific fields', () => {
|
|
|
|
|
|
assert.match(reviewDrawerScript, /activeReviewPanelScope/)
|
|
|
|
|
|
assert.match(reviewDrawerScript, /const reviewOverviewDrawerAvailable = computed\(\(\) => normalizedReviewPanelScope\.value === 'overview'\)/)
|
|
|
|
|
|
assert.match(reviewDrawerScript, /scope === 'documents' && hasDocuments[\s\S]*REVIEW_DRAWER_MODE_DOCUMENTS/)
|
|
|
|
|
|
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(createViewScript, /function normalizeReviewPanelScope\(scope\)/)
|
|
|
|
|
|
assert.match(createViewScript, /canExposeReviewPanelScope\(item\.reviewPanelScope\)/)
|
|
|
|
|
|
assert.match(createViewScript, /currentInsight\.value\.intent === 'agent' && agent[\s\S]*return null/)
|
2026-05-21 09:28:33 +08:00
|
|
|
|
assert.match(createViewScript, /function isTravelReviewPayload\(reviewPayload/)
|
|
|
|
|
|
assert.match(createViewScript, /function resolveReviewTravelTransportType\(reviewPayload/)
|
|
|
|
|
|
assert.match(createViewScript, /label: '交通类型'[\s\S]*modelKey: 'transport_type'/)
|
|
|
|
|
|
assert.match(createViewScript, /label: '酒店名称'[\s\S]*modelKey: 'merchant_name'/)
|
|
|
|
|
|
assert.match(createViewScript, /label: '出差事宜'[\s\S]*editor: 'textarea'[\s\S]*wide: true/)
|
|
|
|
|
|
assert.match(createViewTemplate, /item\.editor === 'textarea'[\s\S]*<textarea/)
|
|
|
|
|
|
assert.match(createViewTemplate, /wide: item\.wide/)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-22 16:00:19 +08:00
|
|
|
|
test('submit composer scopes the side panel to intent overview, document upload, or triggered risk only', () => {
|
|
|
|
|
|
assert.match(submitComposerScript, /function resolveReviewPanelScope\(\{[\s\S]*reviewPayload = null/)
|
|
|
|
|
|
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, /nextInsight\.agent\.reviewPanelScope = assistantMessage\.reviewPanelScope/)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-26 09:15:14 +08:00
|
|
|
|
test('expense query answers keep one clear result structure with document center jump link', () => {
|
2026-05-22 16:00:19 +08:00
|
|
|
|
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\)/)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('backend query response suppresses generic query actions and supports archived filter title', () => {
|
|
|
|
|
|
const responseScript = readFileSync(
|
|
|
|
|
|
fileURLToPath(new URL('../../server/src/app/services/user_agent_response.py', import.meta.url)),
|
|
|
|
|
|
'utf8'
|
|
|
|
|
|
)
|
|
|
|
|
|
const queryScript = readFileSync(
|
|
|
|
|
|
fileURLToPath(new URL('../../server/src/app/services/orchestrator_expense_query.py', import.meta.url)),
|
|
|
|
|
|
'utf8'
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert.match(responseScript, /if payload\.ontology\.intent in \{"query", "compare"\}:[\s\S]*return \[\]/)
|
|
|
|
|
|
assert.match(responseScript, /下面先列出最近 \{query_payload\.preview_count\} 条记录/)
|
|
|
|
|
|
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\}"/)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
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\(\)/)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-21 09:28:33 +08:00
|
|
|
|
test('composer exposes travel calculator and posts spreadsheet-backed result into conversation', () => {
|
2026-05-26 09:15:14 +08:00
|
|
|
|
assert.match(createViewTemplate, /v-if="canShowTravelCalculator" class="travel-calculator-anchor"/)
|
2026-05-21 09:28:33 +08:00
|
|
|
|
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/)
|
2026-05-26 09:15:14 +08:00
|
|
|
|
assert.match(createViewScript, /const canShowTravelCalculator = computed\(\(\) => activeSessionType\.value === SESSION_TYPE_EXPENSE\)/)
|
|
|
|
|
|
assert.match(createViewScript, /function openTravelCalculator\(\) \{[\s\S]*!canShowTravelCalculator\.value[\s\S]*closeTravelCalculator\(\)/)
|
2026-05-21 09:28:33 +08:00
|
|
|
|
assert.match(createViewScript, /function toggleTravelCalculator\(\)/)
|
2026-05-26 09:15:14 +08:00
|
|
|
|
assert.match(createViewScript, /function toggleTravelCalculator\(\) \{[\s\S]*!canShowTravelCalculator\.value[\s\S]*closeTravelCalculator\(\)/)
|
|
|
|
|
|
assert.match(createViewScript, /watch\(canShowTravelCalculator,[\s\S]*closeTravelCalculator\(\)/)
|
2026-05-21 09:28:33 +08:00
|
|
|
|
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(reimbursementService, /export function calculateTravelReimbursement\(payload = \{\}\) \{[\s\S]*\/reimbursements\/travel-calculator/)
|
|
|
|
|
|
})
|
2026-05-21 14:24:51 +08:00
|
|
|
|
|
|
|
|
|
|
test('continuing receipt upload preserves prior review form context', () => {
|
|
|
|
|
|
assert.match(createViewScript, /function buildReviewFormContextFromPayload\(reviewPayload, inlineState = null\)/)
|
|
|
|
|
|
assert.match(createViewScript, /function buildBusinessTimeContextFromReviewValues\(values = \{\}\)/)
|
|
|
|
|
|
assert.match(
|
|
|
|
|
|
createViewScript,
|
|
|
|
|
|
/resolvedUploadDisposition === 'continue_existing'[\s\S]*buildReviewFormContextFromPayload\([\s\S]*activeReviewPayload\.value[\s\S]*reviewInlineForm\.value[\s\S]*extraContext\.review_form_values/s
|
|
|
|
|
|
)
|
|
|
|
|
|
assert.match(
|
|
|
|
|
|
createViewScript,
|
|
|
|
|
|
/inheritedReviewContext\.business_time_context[\s\S]*extraContext\.business_time_context = inheritedReviewContext\.business_time_context/s
|
|
|
|
|
|
)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-06-03 15:46:56 +08:00
|
|
|
|
test('review form context emits ontology fields instead of local aliases', () => {
|
|
|
|
|
|
const context = buildReviewFormContextFromPayload(
|
|
|
|
|
|
{
|
|
|
|
|
|
edit_fields: [
|
|
|
|
|
|
{ key: 'expense_type', value: '' },
|
|
|
|
|
|
{ key: 'occurred_date', value: '' },
|
|
|
|
|
|
{ key: 'transport_type', value: '' },
|
|
|
|
|
|
{ key: 'reason', value: '' },
|
|
|
|
|
|
{ key: 'amount', value: '' },
|
|
|
|
|
|
{ key: 'business_location', value: '' },
|
|
|
|
|
|
{ key: 'attachment_names', value: '' }
|
|
|
|
|
|
]
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
expense_type: '差旅费',
|
|
|
|
|
|
occurred_date: '2026-06-01 至 2026-06-03',
|
|
|
|
|
|
transport_type: '火车',
|
|
|
|
|
|
reason_value: '支撑国网仿生产环境部署',
|
|
|
|
|
|
location: '上海',
|
|
|
|
|
|
amount: '3000',
|
|
|
|
|
|
attachment_names: 'ticket.pdf'
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert.equal(context.review_form_values.expense_type, '差旅费')
|
|
|
|
|
|
assert.equal(context.review_form_values.time_range, '2026-06-01 至 2026-06-03')
|
|
|
|
|
|
assert.equal(context.review_form_values.transport_mode, '火车')
|
|
|
|
|
|
assert.equal(context.review_form_values.reason, '支撑国网仿生产环境部署')
|
|
|
|
|
|
assert.equal(context.review_form_values.attachments, 'ticket.pdf')
|
|
|
|
|
|
assert.equal(context.review_form_values.occurred_date, undefined)
|
|
|
|
|
|
assert.equal(context.review_form_values.transport_type, undefined)
|
|
|
|
|
|
assert.equal(context.review_form_values.reason_value, undefined)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-21 14:24:51 +08:00
|
|
|
|
test('review drawer save action is disabled while receipt recognition is submitting', () => {
|
|
|
|
|
|
assert.match(createViewScript, /const submitting = ref\(false\)/)
|
|
|
|
|
|
assert.match(
|
|
|
|
|
|
createViewScript,
|
|
|
|
|
|
/submitting\.value = true[\s\S]*recognizeOcrFiles\(files\)[\s\S]*submitting\.value = false/s
|
|
|
|
|
|
)
|
|
|
|
|
|
assert.match(
|
|
|
|
|
|
createViewTemplate,
|
|
|
|
|
|
/class="review-side-save-pill"[\s\S]*:disabled="reviewActionBusy \|\| submitting"[\s\S]*@click="saveInlineReviewChanges"/
|
|
|
|
|
|
)
|
|
|
|
|
|
assert.match(
|
|
|
|
|
|
createViewScript,
|
|
|
|
|
|
/async function handleReviewAction\(message, action\) \{[\s\S]*if \(!actionType \|\| submitting\.value \|\| reviewActionBusy\.value \|\| sessionSwitchBusy\.value\) return/
|
|
|
|
|
|
)
|
|
|
|
|
|
assert.match(
|
|
|
|
|
|
createViewScript,
|
|
|
|
|
|
/function saveInlineReviewChanges\(\) \{[\s\S]*\|\| submitting\.value[\s\S]*\|\| sessionSwitchBusy\.value[\s\S]*\) return/
|
|
|
|
|
|
)
|
|
|
|
|
|
})
|
2026-05-21 23:53:03 +08:00
|
|
|
|
|
2026-06-02 14:01:51 +08:00
|
|
|
|
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(
|
|
|
|
|
|
reimbursementFlowScript,
|
|
|
|
|
|
/await Promise\.race\(\[[\s\S]*fetchAgentRunDetail\(flowRunId\.value\)[\s\S]*globalThis\.setTimeout\(\(\) => resolve\(null\), FLOW_RUN_DETAIL_REFRESH_TIMEOUT_MS\)/
|
|
|
|
|
|
)
|
|
|
|
|
|
assert.match(reimbursementFlowScript, /if \(!run\) \{\s*return null\s*}/)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-06-01 17:07:14 +08:00
|
|
|
|
test('draft creation keeps detail-scoped attachment persistence alive before close', () => {
|
2026-05-21 23:53:03 +08:00
|
|
|
|
assert.match(
|
|
|
|
|
|
submitComposerScript,
|
2026-06-01 17:07:14 +08:00
|
|
|
|
/const persistComposerFilesToDraft = async \(\) => \{[\s\S]*const syncResult = await syncComposerFilesToDraft\(resolvedDraftClaimId, files\)[\s\S]*persistSessionState\(\)[\s\S]*if \(detailScopedUpload\) \{[\s\S]*emitRequestUpdated\?\.\(\{/s
|
2026-05-21 23:53:03 +08:00
|
|
|
|
)
|
2026-06-01 17:07:14 +08:00
|
|
|
|
assert.match(
|
2026-05-21 23:53:03 +08:00
|
|
|
|
submitComposerScript,
|
2026-06-01 17:07:14 +08:00
|
|
|
|
/const persistTask = persistComposerFilesToDraft\(\)[\s\S]*if \(detailScopedUpload\) \{[\s\S]*await persistTask[\s\S]*\} else \{[\s\S]*void persistTask[\s\S]*\}/s
|
2026-05-21 23:53:03 +08:00
|
|
|
|
)
|
|
|
|
|
|
assert.ok(
|
2026-05-22 08:58:59 +08:00
|
|
|
|
submitComposerScript.indexOf('replaceMessage(pendingMessage.id, assistantMessage)') <
|
2026-06-01 17:07:14 +08:00
|
|
|
|
submitComposerScript.indexOf('const persistTask = persistComposerFilesToDraft()'),
|
2026-05-22 08:58:59 +08:00
|
|
|
|
'assistant response should render before background attachment persistence starts'
|
2026-05-21 23:53:03 +08:00
|
|
|
|
)
|
2026-06-01 17:07:14 +08:00
|
|
|
|
assert.match(submitComposerScript, /source: 'detail-smart-entry-attachment-sync'/)
|
|
|
|
|
|
assert.match(submitComposerScript, /uploadedCount: Number\(syncResult\?\.uploadedCount \|\| 0\)/)
|
2026-05-21 23:53:03 +08:00
|
|
|
|
assert.match(attachmentsScript, /function normalizeAttachmentMatchName\(value\)/)
|
|
|
|
|
|
assert.match(attachmentsScript, /const normalizedMatchBuckets = new Map\(\)/)
|
|
|
|
|
|
assert.match(
|
|
|
|
|
|
attachmentsScript,
|
2026-05-30 15:46:51 +08:00
|
|
|
|
/nextExactMatch \|\| nextNormalizedMatch \|\| fallbackMatch \|\| emptyFallbackMatch/
|
2026-05-21 23:53:03 +08:00
|
|
|
|
)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-30 15:46:51 +08:00
|
|
|
|
test('detail smart-entry receipt sync uploads files to existing empty items and creates a row when needed', async () => {
|
|
|
|
|
|
const uploadCalls = []
|
|
|
|
|
|
let createCount = 0
|
|
|
|
|
|
const claimSnapshots = [
|
|
|
|
|
|
{
|
|
|
|
|
|
items: [
|
|
|
|
|
|
{ id: 'item-empty-1', item_type: 'hotel_ticket', invoice_id: '' },
|
|
|
|
|
|
{ id: 'item-persisted', item_type: 'train_ticket', invoice_id: 'claim-1/item-persisted/old.pdf' }
|
|
|
|
|
|
]
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
items: [
|
|
|
|
|
|
{ id: 'item-empty-1', item_type: 'hotel_ticket', invoice_id: '' },
|
|
|
|
|
|
{ id: 'item-persisted', item_type: 'train_ticket', invoice_id: 'claim-1/item-persisted/old.pdf' }
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
const attachments = useTravelReimbursementAttachments({
|
|
|
|
|
|
isKnowledgeSession: ref(false),
|
|
|
|
|
|
reviewFilePreviews: ref([]),
|
|
|
|
|
|
linkedRequest: ref({}),
|
|
|
|
|
|
draftClaimId: ref('claim-1'),
|
|
|
|
|
|
activeReviewPayload: ref(null),
|
|
|
|
|
|
reviewInlinePendingFiles: ref([]),
|
|
|
|
|
|
reviewInlineForm: ref({}),
|
|
|
|
|
|
reviewInlineEditorKey: ref(''),
|
|
|
|
|
|
composerUploadIntent: ref(''),
|
|
|
|
|
|
submitting: ref(false),
|
|
|
|
|
|
reviewActionBusy: ref(false),
|
|
|
|
|
|
toast: () => {},
|
|
|
|
|
|
fileInputRef: ref(null),
|
|
|
|
|
|
createExpenseClaimItem: async () => {
|
|
|
|
|
|
createCount += 1
|
|
|
|
|
|
return {
|
|
|
|
|
|
items: [
|
|
|
|
|
|
{ id: 'item-created-1', item_type: 'taxi_receipt', invoice_id: '' }
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
fetchExpenseClaimDetail: async () => claimSnapshots.shift() || { items: [] },
|
|
|
|
|
|
fetchExpenseClaimItemAttachmentMeta: async () => null,
|
|
|
|
|
|
fetchExpenseClaimAttachmentAsset: async () => new Blob(['preview']),
|
|
|
|
|
|
uploadExpenseClaimItemAttachment: async (claimId, itemId, file) => {
|
|
|
|
|
|
uploadCalls.push({ claimId, itemId, fileName: file.name })
|
|
|
|
|
|
return {}
|
|
|
|
|
|
},
|
|
|
|
|
|
extractReviewAttachmentNames: () => [],
|
|
|
|
|
|
mergeFilesWithLimit: (existing, incoming) => ({ files: [...existing, ...incoming], overflowCount: 0 }),
|
|
|
|
|
|
mergeFilePreviews: (existing, incoming) => [...existing, ...incoming],
|
|
|
|
|
|
isTemporaryPreviewUrl: () => false,
|
|
|
|
|
|
resolveAttachmentPreviewKind: () => '',
|
|
|
|
|
|
resolveDocumentPreview: () => null,
|
|
|
|
|
|
buildFilePreviews: () => [],
|
|
|
|
|
|
buildFileIdentity: (file) => file.name,
|
|
|
|
|
|
MAX_ATTACHMENTS: 5,
|
|
|
|
|
|
VISIBLE_ATTACHMENT_CHIPS: 3,
|
|
|
|
|
|
clearInlineReviewFieldError: () => {}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const result = await attachments.syncComposerFilesToDraft('claim-1', [
|
|
|
|
|
|
{ name: 'hotel.pdf' },
|
|
|
|
|
|
{ name: 'taxi.pdf' }
|
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
|
|
assert.deepEqual(uploadCalls, [
|
|
|
|
|
|
{ claimId: 'claim-1', itemId: 'item-empty-1', fileName: 'hotel.pdf' },
|
|
|
|
|
|
{ claimId: 'claim-1', itemId: 'item-created-1', fileName: 'taxi.pdf' }
|
|
|
|
|
|
])
|
|
|
|
|
|
assert.equal(createCount, 1)
|
|
|
|
|
|
assert.equal(result.uploadedCount, 2)
|
|
|
|
|
|
assert.equal(result.skippedCount, 0)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-21 23:53:03 +08:00
|
|
|
|
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\)"/
|
|
|
|
|
|
)
|
|
|
|
|
|
assert.doesNotMatch(
|
|
|
|
|
|
reviewActionsScript,
|
|
|
|
|
|
/messages\.value\.push\(\s*createMessage\('assistant', actionConfig\.successMessage/
|
|
|
|
|
|
)
|
|
|
|
|
|
})
|
2026-05-22 16:00:19 +08:00
|
|
|
|
|
|
|
|
|
|
test('saved draft review messages stop showing the save-draft prompt', () => {
|
|
|
|
|
|
const reviewPayload = {
|
|
|
|
|
|
slot_cards: [
|
|
|
|
|
|
{ key: 'amount', label: '金额', title: '金额', status: 'missing', required: true },
|
|
|
|
|
|
{ key: 'attachments', label: '票据状态', title: '票据状态', status: 'missing', required: true }
|
|
|
|
|
|
],
|
|
|
|
|
|
missing_slots: ['金额', '票据附件'],
|
|
|
|
|
|
risk_briefs: [],
|
|
|
|
|
|
confirmation_actions: [
|
|
|
|
|
|
{ label: '保存为草稿', action_type: 'save_draft' }
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
const followup = buildReviewPlainFollowupCopy(reviewPayload, { savedDraft: true })
|
|
|
|
|
|
|
2026-06-02 14:01:51 +08:00
|
|
|
|
assert.equal(followup.lead, '后续处理:')
|
|
|
|
|
|
assert.match(followup.summary, /自动检测/)
|
|
|
|
|
|
assert.match(followup.summary, /继续上传/)
|
|
|
|
|
|
assert.equal(followup.items.length, 0)
|
|
|
|
|
|
assert.doesNotMatch(followup.summary, /当前草稿待完善|必须/)
|
2026-05-22 16:00:19 +08:00
|
|
|
|
assert.doesNotMatch(followup.summary, /点击|点“草稿”|保存为草稿|临时保存|暂存/)
|
2026-06-02 14:01:51 +08:00
|
|
|
|
assert.match(messageItemTemplate, /buildReviewPlainFollowupForMessage\(message\)/)
|
2026-05-22 16:00:19 +08:00
|
|
|
|
assert.match(createViewScript, /function isDraftSavedReviewMessage\(message\)/)
|
|
|
|
|
|
assert.match(createViewScript, /function canUseInlineSaveDraft\(message\)[\s\S]*isDraftSavedReviewMessage\(message\)/)
|
|
|
|
|
|
})
|
2026-06-02 14:01:51 +08:00
|
|
|
|
|
|
|
|
|
|
test('guided save draft emits refresh and exposes reimbursement draft detail card', () => {
|
|
|
|
|
|
assert.match(
|
|
|
|
|
|
createViewScript,
|
|
|
|
|
|
/emitDraftSaved:\s*\(payload\)\s*=>\s*emit\('draft-saved', payload\)/
|
|
|
|
|
|
)
|
|
|
|
|
|
assert.match(submitComposerScript, /function emitSavedDraftRefresh\(draftPayload\)/)
|
|
|
|
|
|
assert.match(
|
|
|
|
|
|
submitComposerScript,
|
|
|
|
|
|
/emitSavedDraftRefresh\(payload\?\.result\?\.draft_payload \|\| null\)/
|
|
|
|
|
|
)
|
|
|
|
|
|
assert.match(createViewScript, /function shouldShowDraftSavedCard\(message\)/)
|
2026-06-06 17:19:07 +08:00
|
|
|
|
assert.match(createViewScript, /function canOpenDraftDetail\(message\)/)
|
2026-06-02 14:01:51 +08:00
|
|
|
|
assert.match(createViewScript, /function resolveReimbursementDraftClaimNo\(draftPayload\)/)
|
|
|
|
|
|
assert.doesNotMatch(createViewScript, /function buildReimbursementDraftSummaryItems\(draftPayload\)/)
|
2026-06-06 17:19:07 +08:00
|
|
|
|
assert.match(messageItemTemplate, /v-if="ui\.canOpenDraftDetail\(message\)"/)
|
2026-06-02 14:01:51 +08:00
|
|
|
|
assert.match(messageItemTemplate, /class="reimbursement-draft-link"/)
|
2026-06-06 17:19:07 +08:00
|
|
|
|
assert.match(messageItemTemplate, /reimbursement-draft-pending-detail/)
|
2026-06-02 14:01:51 +08:00
|
|
|
|
})
|