feat: 报销审批流重构与管家计划全链路贯通
- 重构报销状态注册表、审批流路由与平台风险标记 - 完善管家意图规划器与模型计划构建器全链路 - 新增 OCR Worker 脚本、数据库会话管理与通知状态 - 优化文档中心、日志视图、预算中心与员工管理交互 - 增强工作台摘要、图标资源与全局主题样式 - 补充审批路由、状态注册、OCR 服务与管家规划器测试覆盖
This commit is contained in:
@@ -51,11 +51,6 @@ const chatViewTemplate = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/ChatView.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const operationFeedbackInlineTemplate = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/shared/OperationFeedbackInlineCard.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('application and reimbursement entries open the same financial assistant modal', () => {
|
||||
assert.match(appShellRouteView, /<TravelReimbursementCreateView[\s\S]*:entry-source="smartEntryContext\.source"/)
|
||||
assert.match(appShellRouteView, /@create-request="openTravelCreate"/)
|
||||
@@ -134,8 +129,10 @@ test('application edit prefill opens assistant without auto submit', () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('financial assistant toolbar renders four isolated assistant sessions', () => {
|
||||
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/)
|
||||
@@ -199,38 +196,39 @@ test('assistant message meta hides internal routing and permission chips', () =>
|
||||
assert.doesNotMatch(chatViewTemplate, /agent-meta-row|agent-meta-chip/)
|
||||
})
|
||||
|
||||
test('assistant operation feedback is inline and persists run context', () => {
|
||||
test('assistant message action toolbar collects lightweight feedback', () => {
|
||||
assert.doesNotMatch(appShellRouteView, /<OperationFeedbackDialog/)
|
||||
assert.doesNotMatch(appShellRouteView, /@operation-completed="handleOperationCompleted"/)
|
||||
assert.doesNotMatch(appShellComposable, /useOperationFeedback/)
|
||||
assert.match(messageItemTemplate, /<OperationFeedbackInlineCard/)
|
||||
assert.match(messageItemTemplate, /class="message-feedback-bubble"/)
|
||||
assert.match(messageItemTemplate, /:submitted="Boolean\(message\.operationFeedback\?\.submitted\)"/)
|
||||
assert.match(messageItemTemplate, /:submitted-rating="Number\(message\.operationFeedback\?\.rating \|\| 0\)"/)
|
||||
assert.doesNotMatch(messageItemTemplate, /<OperationFeedbackInlineCard/)
|
||||
assert.match(messageItemTemplate, /class="message-action-toolbar"/)
|
||||
assert.match(messageItemTemplate, /ui\.shouldShowAssistantMessageActions\(message\)/)
|
||||
assert.match(messageItemTemplate, /ui\.copyAssistantMessage\(message\)/)
|
||||
assert.match(messageItemTemplate, /ui\.speakAssistantMessage\(message\)/)
|
||||
assert.match(messageItemTemplate, /rating:\s*5,\s*reason:\s*'thumbs_up'/)
|
||||
assert.match(messageItemTemplate, /rating:\s*1,\s*reason:\s*'thumbs_down'/)
|
||||
assert.match(messageItemTemplate, /mdi mdi-content-copy/)
|
||||
assert.match(messageItemTemplate, /mdi mdi-volume-high/)
|
||||
assert.match(messageItemTemplate, /mdi mdi-thumb-up-outline/)
|
||||
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, /&& !feedback\.dismissed/)
|
||||
assert.doesNotMatch(assistantScript, /&& !feedback\.submitted/)
|
||||
assert.match(assistantScript, /submitted:\s*true/)
|
||||
assert.match(assistantScript, /dismissed:\s*false/)
|
||||
assert.doesNotMatch(assistantScript, /emit\('operation-completed'/)
|
||||
assert.match(assistantSubmitComposerScript, /emitOperationCompleted\?\.\(payload/)
|
||||
assert.match(assistantSubmitComposerScript, /operationFeedback:\s*buildOperationFeedbackState/)
|
||||
assert.match(assistantSubmitComposerScript, /rating:\s*0/)
|
||||
assert.match(operationFeedbackInlineTemplate, /v-for="option in ratingOptions"/)
|
||||
assert.match(operationFeedbackInlineTemplate, /is-submitted/)
|
||||
assert.match(operationFeedbackInlineTemplate, /submittedRating/)
|
||||
assert.match(operationFeedbackInlineTemplate, /感谢您的反馈。谢谢/)
|
||||
assert.match(operationFeedbackInlineTemplate, /busy \|\| submitted/)
|
||||
assert.match(operationFeedbackInlineTemplate, /role="radiogroup"/)
|
||||
assert.match(operationFeedbackInlineTemplate, /handleRatingKeydown/)
|
||||
assert.match(operationFeedbackInlineTemplate, /operation-feedback-stars/)
|
||||
assert.match(operationFeedbackInlineTemplate, /score > 3/)
|
||||
assert.match(operationFeedbackInlineTemplate, /v-if="showReasonInput"/)
|
||||
assert.match(operationFeedbackInlineTemplate, /稍后/)
|
||||
|
||||
const context = normalizeOperationFeedbackContext(
|
||||
{
|
||||
|
||||
@@ -25,6 +25,17 @@ test('document center archived rows are detected from archive flag or request st
|
||||
expense_type: 'travel_application'
|
||||
}
|
||||
}),
|
||||
false
|
||||
)
|
||||
assert.equal(
|
||||
isArchivedDocumentRow({
|
||||
rawRequest: {
|
||||
status: 'approved',
|
||||
approval_stage: '申请归档',
|
||||
claim_no: 'AP-20260525120000-ABCDEFGH',
|
||||
expense_type: 'travel_application'
|
||||
}
|
||||
}),
|
||||
true
|
||||
)
|
||||
assert.equal(
|
||||
|
||||
@@ -2,13 +2,17 @@ import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
buildDocumentViewedStatePatch,
|
||||
buildDocumentsViewedStatePatches,
|
||||
countNewDocuments,
|
||||
isNewDocument,
|
||||
markDocumentViewed,
|
||||
markDocumentsViewed,
|
||||
mergeNotificationStatesIntoViewedDocumentKeys,
|
||||
readDocumentScope,
|
||||
readViewedDocumentKeys,
|
||||
resolveDocumentNewKey,
|
||||
resolveDocumentNotificationId,
|
||||
writeDocumentScope
|
||||
} from '../src/utils/documentCenterNewState.js'
|
||||
import { buildDocumentInboxRows } from '../src/composables/useDocumentCenterInbox.js'
|
||||
@@ -28,6 +32,38 @@ function createMemoryStorage(initial = {}) {
|
||||
test('document center new state resolves source scoped document keys', () => {
|
||||
assert.equal(resolveDocumentNewKey({ source: 'archive', claimId: 'claim-1' }), 'archive:claim-1')
|
||||
assert.equal(resolveDocumentNewKey({ source: 'approval', documentNo: 'EXP-1' }), 'approval:EXP-1')
|
||||
assert.equal(
|
||||
resolveDocumentNotificationId({ source: 'owned', claimId: 'claim-1', documentKey: 'owned:claim-1' }),
|
||||
'document:owned:claim-1'
|
||||
)
|
||||
})
|
||||
|
||||
test('document center merges backend notification states into viewed keys', () => {
|
||||
const storage = createMemoryStorage()
|
||||
const viewedKeys = mergeNotificationStatesIntoViewedDocumentKeys([
|
||||
{ notification_id: 'document:owned:claim-1', read_at: '2026-06-05T09:00:00+08:00' },
|
||||
{ notificationId: 'document:approval:claim-2', hiddenAt: '2026-06-05T09:01:00+08:00' },
|
||||
{ notification_id: 'workbench:todo:claim-3', read_at: '2026-06-05T09:02:00+08:00' }
|
||||
], readViewedDocumentKeys(storage), storage)
|
||||
|
||||
assert.equal(isNewDocument({ source: 'owned', claimId: 'claim-1' }, viewedKeys), false)
|
||||
assert.equal(isNewDocument({ source: 'approval', claimId: 'claim-2' }, viewedKeys), false)
|
||||
assert.deepEqual([...readViewedDocumentKeys(storage)], ['owned:claim-1', 'approval:claim-2'])
|
||||
})
|
||||
|
||||
test('document center builds backend viewed-state patches for unread rows', () => {
|
||||
const rows = [
|
||||
{ source: 'owned', claimId: 'claim-1', documentKey: 'owned:claim-1' },
|
||||
{ source: 'approval', claimId: 'claim-2', documentKey: 'approval:claim-2' },
|
||||
{ source: 'archive', claimId: 'claim-3', documentKey: 'archive:claim-3' }
|
||||
]
|
||||
const patches = buildDocumentsViewedStatePatches(rows, new Set(['owned:claim-1']))
|
||||
|
||||
assert.deepEqual(patches.map((patch) => patch.notification_id), ['document:approval:claim-2'])
|
||||
assert.equal(patches[0].read, true)
|
||||
assert.equal(patches[0].hidden, false)
|
||||
assert.equal(patches[0].context_json.kind, 'document')
|
||||
assert.equal(buildDocumentViewedStatePatch(rows[2]), null)
|
||||
})
|
||||
|
||||
test('document center new state counts unseen documents and persists viewed rows', () => {
|
||||
|
||||
@@ -208,6 +208,9 @@ test('documents center category tabs render bubble counts for new documents', ()
|
||||
|
||||
test('documents center can mark all unread documents as read from toolbar', () => {
|
||||
assert.match(documentsCenterView, /markDocumentsViewed/)
|
||||
assert.match(documentsCenterView, /patchNotificationStates/)
|
||||
assert.match(documentsCenterView, /buildDocumentsViewedStatePatches/)
|
||||
assert.match(documentsCenterView, /mergeNotificationStatesIntoViewedDocumentKeys/)
|
||||
assert.match(
|
||||
documentsCenterView,
|
||||
/const allReadableDocumentRows = computed\(\(\) => \[[\s\S]*nonArchivedRows\.value[\s\S]*filterApplicationScopeNewRows\(applicationScopeRows\.value\)[\s\S]*approvalRows\.value/
|
||||
@@ -222,6 +225,10 @@ test('documents center can mark all unread documents as read from toolbar', () =
|
||||
documentsCenterView,
|
||||
/function markAllDocumentsRead\(\) \{[\s\S]*viewedDocumentKeys\.value = markDocumentsViewed\(allReadableDocumentRows\.value, viewedDocumentKeys\.value\)/
|
||||
)
|
||||
assert.match(
|
||||
documentsCenterView,
|
||||
/function markAllDocumentsRead\(\) \{[\s\S]*const viewedPatches = buildDocumentsViewedStatePatches\(allReadableDocumentRows\.value, viewedDocumentKeys\.value\)[\s\S]*syncDocumentViewedPatches\(viewedPatches\)/
|
||||
)
|
||||
assert.match(
|
||||
documentsCenterView,
|
||||
/function resolveLiveDocumentRow\(row\) \{[\s\S]*isNewDocument: isNewDocument\(row, viewedDocumentKeys\.value\)[\s\S]*const visibleRows = computed\(\(\) => \{[\s\S]*\.map\(resolveLiveDocumentRow\)/
|
||||
@@ -232,9 +239,10 @@ 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(documentsCenterView, /buildDocumentViewedStatePatch\(row\)/)
|
||||
assert.match(
|
||||
documentsCenterView,
|
||||
/function openDocument\(row\) \{[\s\S]*writeDocumentScope\(activeScopeTab\.value, scopeTabs\)[\s\S]*viewedDocumentKeys\.value = markDocumentViewed\(row, viewedDocumentKeys\.value\)[\s\S]*emit\('open-document', row\.rawRequest \|\| row\)/
|
||||
/function openDocument\(row\) \{[\s\S]*writeDocumentScope\(activeScopeTab\.value, scopeTabs\)[\s\S]*viewedDocumentKeys\.value = markDocumentViewed\(row, viewedDocumentKeys\.value\)[\s\S]*syncDocumentViewedPatches\(\[viewedPatch\]\)[\s\S]*emit\('open-document', row\.rawRequest \|\| row\)/
|
||||
)
|
||||
assert.match(documentsCenterStyles, /\.new-document-badge\s*\{[\s\S]*background:\s*#fff5f5;/)
|
||||
assert.match(documentsCenterStyles, /\.new-document-badge\s*\{[\s\S]*border:\s*1px solid #fecaca;/)
|
||||
|
||||
@@ -15,7 +15,10 @@ import {
|
||||
buildLocalApplicationPreview,
|
||||
buildLocalApplicationPreviewMessage,
|
||||
buildModelRefinedApplicationPreview,
|
||||
applicationDateRangesOverlap,
|
||||
normalizeApplicationPreview,
|
||||
normalizeTransportModeOption,
|
||||
resolveApplicationDateRange,
|
||||
resolveApplicationTimeLabel,
|
||||
shouldUseLocalApplicationPreview
|
||||
} from '../src/utils/expenseApplicationPreview.js'
|
||||
@@ -36,6 +39,17 @@ import {
|
||||
createMessage as createConversationMessage,
|
||||
hasMeaningfulSessionMessages
|
||||
} from '../src/views/scripts/travelReimbursementConversationModel.js'
|
||||
import {
|
||||
buildStewardSuggestedActions,
|
||||
filterStewardBlockingMissingFields
|
||||
} from '../src/views/scripts/stewardPlanModel.js'
|
||||
import {
|
||||
buildStewardFieldCompletionContinuation,
|
||||
buildStewardFieldCompletionRawText
|
||||
} from '../src/views/scripts/stewardFieldCompletionModel.js'
|
||||
import {
|
||||
shouldUseBudgetCompileReport
|
||||
} from '../src/views/scripts/budgetAssistantReportModel.js'
|
||||
import { useTravelReimbursementFlow } from '../src/views/scripts/useTravelReimbursementFlow.js'
|
||||
import { useApplicationPreviewEditor } from '../src/views/scripts/useApplicationPreviewEditor.js'
|
||||
|
||||
@@ -43,10 +57,22 @@ const submitComposerScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const stewardServiceScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/services/steward.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const createViewScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const stewardPlanFlowScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useStewardPlanFlow.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const stewardFieldCompletionScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/stewardFieldCompletionModel.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const createViewTemplate = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/TravelReimbursementCreateView.vue', import.meta.url)),
|
||||
'utf8'
|
||||
@@ -506,7 +532,7 @@ test('application session shows intent flow, persists preview, and supports inli
|
||||
assert.match(submitComposerScript, /startFlowStep\('intent'/)
|
||||
assert.match(submitComposerScript, /startFlowStep\('application-review-preview'/)
|
||||
assert.match(submitComposerScript, /completeFlowStep\('intent'/)
|
||||
assert.doesNotMatch(submitComposerScript, /insightPanelCollapsed\.value = true/)
|
||||
assert.match(submitComposerScript, /function resetStewardDelegatedInsightState\(\) \{[\s\S]*insightPanelCollapsed\.value = true/)
|
||||
assert.doesNotMatch(submitComposerScript, /void refineApplicationPreviewWithModel/)
|
||||
assert.match(submitComposerScript, /return null[\s\S]*const hasUnsavedReviewDraft/)
|
||||
assert.ok(
|
||||
@@ -542,9 +568,16 @@ test('application session shows intent flow, persists preview, and supports inli
|
||||
assert.match(messageItemTemplate, /申请单据已生成/)
|
||||
assert.match(messageItemTemplate, /ui\.shouldShowDraftSavedCard\(message\)/)
|
||||
assert.match(messageItemTemplate, /报销草稿已生成/)
|
||||
assert.match(messageItemTemplate, /报销草稿待保存/)
|
||||
assert.match(messageItemTemplate, /ui\.resolveReimbursementDraftClaimNo\(message\.draftPayload\)/)
|
||||
assert.match(messageItemTemplate, /v-if="ui\.canOpenDraftDetail\(message\)"/)
|
||||
assert.match(messageItemTemplate, /class="reimbursement-draft-link"/)
|
||||
assert.match(messageItemTemplate, /查看详情/)
|
||||
assert.match(messageItemTemplate, /class="reimbursement-draft-pending-detail"/)
|
||||
assert.match(messageItemTemplate, /保存后可查看详情/)
|
||||
assert.match(createViewScript, /function canOpenDraftDetail\(message\)/)
|
||||
assert.match(createViewScript, /canOpenDraftDetail,/)
|
||||
assert.match(createViewScript, /保存后生成/)
|
||||
assert.doesNotMatch(messageItemTemplate, /ui\.buildReimbursementDraftSummaryItems\(message\.draftPayload\)/)
|
||||
assert.doesNotMatch(messageItemTemplate, /可以继续上传票据,我会归集到这张草稿。/)
|
||||
assert.ok(
|
||||
@@ -558,9 +591,15 @@ test('application session shows intent flow, persists preview, and supports inli
|
||||
assert.match(messageItemTemplate, /完整审批链、附件和明细可在单据详情中[\s\S]*application-draft-detail-link[\s\S]*>查看<\/button>/)
|
||||
assert.doesNotMatch(messageItemTemplate, /application-draft-detail-btn/)
|
||||
assert.match(messageItemTemplate, /ui\.openApplicationDraftDetail\(message\)/)
|
||||
assert.match(messageItemTemplate, /<OperationFeedbackInlineCard/)
|
||||
assert.match(messageItemTemplate, /ui\.isOperationFeedbackVisible\(message\)/)
|
||||
assert.match(messageItemTemplate, /ui\.submitOperationFeedbackForMessage\(message, \$event\)/)
|
||||
assert.doesNotMatch(messageItemTemplate, /<OperationFeedbackInlineCard/)
|
||||
assert.match(messageItemTemplate, /class="message-action-toolbar"/)
|
||||
assert.match(messageItemTemplate, /ui\.shouldShowAssistantMessageActions\(message\)/)
|
||||
assert.match(messageItemTemplate, /ui\.copyAssistantMessage\(message\)/)
|
||||
assert.match(messageItemTemplate, /ui\.speakAssistantMessage\(message\)/)
|
||||
assert.match(messageItemTemplate, /ui\.submitOperationFeedbackForMessage\(message, \{ rating: 5, reason: 'thumbs_up' \}\)/)
|
||||
assert.match(messageItemTemplate, /ui\.submitOperationFeedbackForMessage\(message, \{ rating: 1, reason: 'thumbs_down' \}\)/)
|
||||
assert.match(messageItemTemplate, /mdi mdi-content-copy/)
|
||||
assert.match(messageItemTemplate, /mdi mdi-volume-high/)
|
||||
assert.match(submitComposerScript, /employee_grade:\s*user\.grade \|\| user\.employeeGrade \|\| user\.employee_grade/)
|
||||
assert.match(submitComposerScript, /employeeGrade:\s*user\.grade \|\| user\.employeeGrade \|\| user\.employee_grade/)
|
||||
assert.match(createViewTemplate, /'has-insight': hasInsightPanelContent && showInsightPanel/)
|
||||
@@ -580,7 +619,12 @@ test('application session shows intent flow, persists preview, and supports inli
|
||||
assert.match(createViewScript, /onComposerDateSelection: applyLinkedApplicationPreviewDateSelection/)
|
||||
assert.match(createViewScript, /function openApplicationPreviewEditorFromUi/)
|
||||
assert.match(createViewScript, /syncComposerDateFromApplicationEditor/)
|
||||
assert.match(createViewScript, /function shouldShowAssistantMessageActions/)
|
||||
assert.match(createViewScript, /function buildMessageOperationFeedbackContext/)
|
||||
assert.match(createViewScript, /function isMessageFeedbackSelected/)
|
||||
assert.match(createViewScript, /function submitOperationFeedbackForMessage/)
|
||||
assert.match(createViewScript, /const stewardSubmitContinuation = message\?\.stewardContinuation \|\| null/)
|
||||
assert.match(createViewScript, /stewardContinuation:\s*stewardSubmitContinuation/)
|
||||
assert.match(createViewTemplate, /handleComposerDateInputChange\('single'\)/)
|
||||
assert.match(createViewTemplate, /handleComposerDateInputChange\('range-start'\)/)
|
||||
assert.match(createViewTemplate, /handleComposerDateInputChange\('range-end'\)/)
|
||||
@@ -621,9 +665,9 @@ test('application session shows intent flow, persists preview, and supports inli
|
||||
assert.match(flowScript, /return null/)
|
||||
assert.match(flowScript, /申请单提交成功/)
|
||||
assert.match(submitComposerScript, /const isApplicationSubmitOperation = feedbackOperationType === 'submit_application'/)
|
||||
assert.match(submitComposerScript, /if \(isApplicationSubmitOperation\) \{[\s\S]*startFlowStep\('application-submit-success'/)
|
||||
assert.match(submitComposerScript, /else if \(rawText && !reviewAction\) \{[\s\S]*startFlowStep\('intent'/)
|
||||
assert.match(submitComposerScript, /if \(!isApplicationSubmitOperation\) \{[\s\S]*startExpenseClaimDraftFlowStep/)
|
||||
assert.match(submitComposerScript, /if \(!stewardDelegated && isApplicationSubmitOperation\) \{[\s\S]*startFlowStep\('application-submit-success'/)
|
||||
assert.match(submitComposerScript, /else if \(!stewardDelegated && rawText && !reviewAction\) \{[\s\S]*startFlowStep\('intent'/)
|
||||
assert.match(submitComposerScript, /if \(!isApplicationSubmitOperation && !stewardDelegated\) \{[\s\S]*startExpenseClaimDraftFlowStep/)
|
||||
assert.match(flowScript, /function resolveDurationFromFields/)
|
||||
assert.match(flowScript, /function resolveStartedTimestamp/)
|
||||
assert.match(flowScript, /function resolveFinishedTimestamp/)
|
||||
@@ -631,6 +675,261 @@ test('application session shows intent flow, persists preview, and supports inli
|
||||
assert.match(flowScript, /refreshCompleted/)
|
||||
})
|
||||
|
||||
test('steward application missing transport asks before rendering preview table', () => {
|
||||
assert.match(submitComposerScript, /function shouldPauseStewardApplicationPreview/)
|
||||
assert.match(submitComposerScript, /function sanitizeStewardDelegatedTaskSummary/)
|
||||
assert.match(submitComposerScript, /交通方式和\(\?:预算\|预计\)\?金额待补充/)
|
||||
assert.match(submitComposerScript, /出差费用预算/)
|
||||
assert.match(submitComposerScript, /预估\|预计\|预算\)\?费用/)
|
||||
assert.match(submitComposerScript, /applicationPreview:\s*pauseForMissingFields \? null : applicationPreview/)
|
||||
assert.match(submitComposerScript, /applicationPreview:\s*normalized/)
|
||||
assert.match(submitComposerScript, /请先告诉我你打算怎么出行:\*\*火车、飞机或轮船\*\*/)
|
||||
assert.doesNotMatch(submitComposerScript, /缺少“出行方式”[\s\S]{0,500}更新下方核对表/)
|
||||
|
||||
assert.match(createViewScript, /payload\.applicationPreview/)
|
||||
assert.match(createViewScript, /function continueStewardApplicationFieldCompletion/)
|
||||
assert.match(createViewScript, /submitComposerInternal\(\{[\s\S]*stewardContinuation: continuation/)
|
||||
assert.match(createViewScript, /skipUserMessage:\s*true/)
|
||||
assert.match(createViewScript, /targetMessage\.applicationPreview = normalizeApplicationPreview\(sourcePreview\)/)
|
||||
assert.match(createViewScript, /openApplicationPreviewEditor\(targetMessage, fieldKey/)
|
||||
assert.match(createViewScript, /commitApplicationPreviewEditor\(targetMessage\)/)
|
||||
assert.match(stewardFieldCompletionScript, /transportMode:\s*'transport_mode'/)
|
||||
assert.match(stewardFieldCompletionScript, /模拟查询交通票据/)
|
||||
})
|
||||
|
||||
test('steward field completion reruns application preview instead of directly rendering table', () => {
|
||||
const continuation = {
|
||||
planId: 'steward-plan-transport-gap',
|
||||
currentTaskId: 'task-application-beijing',
|
||||
currentTask: {
|
||||
task_id: 'task-application-beijing',
|
||||
task_type: 'expense_application',
|
||||
summary: '明天前往北京出差3天,支撑国网仿生产部署',
|
||||
ontology_fields: {
|
||||
time_range: '2026-06-05 至 2026-06-07',
|
||||
location: '北京',
|
||||
reason: '支撑国网仿生产部署'
|
||||
},
|
||||
missing_fields: ['transport_mode']
|
||||
},
|
||||
remainingTasks: []
|
||||
}
|
||||
const preview = normalizeApplicationPreview({
|
||||
fields: {
|
||||
applicationType: '差旅费用申请',
|
||||
time: '2026-06-05 至 2026-06-07',
|
||||
location: '北京',
|
||||
reason: '支撑国网仿生产部署',
|
||||
days: '3天',
|
||||
transportMode: ''
|
||||
}
|
||||
})
|
||||
|
||||
const nextContinuation = buildStewardFieldCompletionContinuation(continuation, 'transportMode', '火车')
|
||||
assert.equal(nextContinuation.currentTask.ontology_fields.transport_mode, '火车')
|
||||
assert.deepEqual(nextContinuation.currentTask.missing_fields, [])
|
||||
|
||||
const carryText = buildStewardFieldCompletionRawText({
|
||||
preview,
|
||||
fieldKey: 'transportMode',
|
||||
fieldLabel: '出行方式',
|
||||
value: '火车',
|
||||
continuation: nextContinuation
|
||||
})
|
||||
assert.match(carryText, /用户已补充:出行方式:火车/)
|
||||
assert.match(carryText, /地点:北京/)
|
||||
assert.match(carryText, /天数:3天/)
|
||||
assert.match(carryText, /请先根据已补齐字段模拟查询交通票据/)
|
||||
|
||||
const rebuiltPreview = buildLocalApplicationPreview(carryText, { name: '曹笑竹', grade: 'P5' })
|
||||
assert.equal(rebuiltPreview.fields.location, '北京')
|
||||
assert.equal(rebuiltPreview.fields.transportMode, '火车')
|
||||
assert.equal(rebuiltPreview.fields.days, '3天')
|
||||
})
|
||||
|
||||
test('budget compile report does not steal steward delegated application rerun', () => {
|
||||
const staleBudgetContext = {
|
||||
budgetNo: 'BUD-2026-TECH',
|
||||
mode: 'edit',
|
||||
categoryRows: []
|
||||
}
|
||||
const stewardApplicationText = [
|
||||
'小财管家继续执行申请单字段补齐。',
|
||||
'用户已补充:出行方式:火车。',
|
||||
'地点:北京',
|
||||
'天数:3天',
|
||||
'处理要求:请先根据已补齐字段模拟查询交通票据和费用口径,完成系统预估金额测算,再生成申请单核对表。'
|
||||
].join('\n')
|
||||
|
||||
assert.equal(shouldUseBudgetCompileReport(stewardApplicationText, {
|
||||
sessionType: 'application',
|
||||
entrySource: 'workbench',
|
||||
budgetContext: staleBudgetContext
|
||||
}), false)
|
||||
assert.equal(shouldUseBudgetCompileReport('帮我生成 2026 年 Q3 预算编制建议', {
|
||||
sessionType: 'budget',
|
||||
entrySource: 'budget',
|
||||
budgetContext: staleBudgetContext
|
||||
}), true)
|
||||
assert.match(submitComposerScript, /if \(!stewardDelegated && shouldUseBudgetCompileReport/)
|
||||
})
|
||||
|
||||
test('text confirmation submits pending application preview before replanning steward task', () => {
|
||||
assert.match(stewardServiceScript, /fetchStewardRuntimeDecision/)
|
||||
assert.match(stewardServiceScript, /\/steward\/runtime-decisions/)
|
||||
assert.match(createViewScript, /function buildStewardRuntimeState/)
|
||||
assert.match(createViewScript, /function buildStewardRuntimeFastPathDecision/)
|
||||
assert.match(createViewScript, /function shouldUseStewardRuntimeLlmDecision/)
|
||||
assert.match(createViewScript, /function findPendingSlotSuggestedActionContextByInput/)
|
||||
assert.match(createViewScript, /function shouldPlanNewStewardTasksLocally/)
|
||||
assert.match(createViewScript, /function resolveStewardRuntimeTransportAlias/)
|
||||
assert.match(createViewScript, /const actionTransportAlias = resolveStewardRuntimeTransportAlias/)
|
||||
assert.match(createViewScript, /actionTransportAlias === transportAlias/)
|
||||
assert.match(createViewScript, /next_action:\s*'continue_next_task'/)
|
||||
assert.match(createViewScript, /next_action:\s*'submit_current_application'/)
|
||||
assert.match(createViewScript, /next_action:\s*'fill_current_slot'/)
|
||||
assert.match(createViewScript, /next_action:\s*'plan_new_tasks'/)
|
||||
assert.match(createViewScript, /suppressUserEcho:\s*userMessageAlreadyAdded/)
|
||||
assert.match(createViewScript, /if \(!action\?\.suppressUserEcho\) \{[\s\S]*messages\.value\.push\(createMessage\('user', userText\)\)/)
|
||||
assert.match(createViewScript, /skipApplicationModelReview:\s*true/)
|
||||
assert.match(createViewScript, /skipApplicationModelReview:\s*targetSessionType === SESSION_TYPE_APPLICATION/)
|
||||
assert.match(createViewScript, /skipStewardSlotDecision:\s*targetSessionType === SESSION_TYPE_APPLICATION/)
|
||||
assert.match(submitComposerScript, /skipModelReview:\s*Boolean\(stewardDelegated && options\.skipApplicationModelReview\)/)
|
||||
assert.match(submitComposerScript, /if \(options\.skipModelReview\) \{[\s\S]*结构化快路径/)
|
||||
assert.match(submitComposerScript, /const localPauseForMissingFields = shouldPauseStewardApplicationPreview\(applicationPreview\)/)
|
||||
assert.match(submitComposerScript, /const shouldFetchSlotDecision = localPauseForMissingFields && !options\.skipStewardSlotDecision/)
|
||||
assert.match(submitComposerScript, /const slotDecision = shouldFetchSlotDecision[\s\S]*fetchStewardApplicationSlotDecision/)
|
||||
assert.match(submitComposerScript, /const pendingSuggestedActions = Array\.isArray\(finalExtras\.suggestedActions\)/)
|
||||
assert.match(submitComposerScript, /message\.suggestedActions = pendingSuggestedActions[\s\S]*message\.stewardPlan = buildStewardDelegatedPlan\(continuation, \[\.\.\.typedEvents\], 'typing'\)/)
|
||||
assert.match(createViewScript, /async function handleStewardRuntimeDecision/)
|
||||
assert.match(createViewScript, /const runtimeState = buildStewardRuntimeState\(\)/)
|
||||
assert.match(createViewScript, /if \(!hasActiveStewardRuntimeDecisionContext\(runtimeState\)\) \{[\s\S]*return false/)
|
||||
assert.match(createViewScript, /function pushStewardRuntimeUserMessage\(userText = ''\)/)
|
||||
assert.match(createViewScript, /const userMessageAlreadyAdded = options\.skipUserMessage[\s\S]*pushStewardRuntimeUserMessage\(rawText\)/)
|
||||
assert.match(createViewScript, /const fastDecision = buildStewardRuntimeFastPathDecision\(rawText, runtimeState\)[\s\S]*submitStewardPlan\(\{[\s\S]*skipUserMessage: userMessageAlreadyAdded \|\| options\.skipUserMessage/)
|
||||
assert.match(createViewScript, /executeStewardRuntimeDecision\(fastDecision, rawText, \{ userMessageAlreadyAdded \}\)[\s\S]*if \(!shouldUseStewardRuntimeLlmDecision\(rawText, runtimeState\)\)/)
|
||||
assert.match(createViewScript, /if \(!shouldUseStewardRuntimeLlmDecision\(rawText, runtimeState\)\)[\s\S]*fetchStewardRuntimeDecision/)
|
||||
assert.match(createViewScript, /executeStewardRuntimeDecision\(decision, rawText, \{ userMessageAlreadyAdded \}\)/)
|
||||
assert.match(createViewScript, /skipUserMessage: userMessageAlreadyAdded \|\| options\.skipUserMessage/)
|
||||
assert.match(createViewScript, /fetchStewardRuntimeDecision\(\{[\s\S]*runtime_state: runtimeState/)
|
||||
assert.match(createViewScript, /if \(await handleStewardRuntimeDecision\(options\)\) \{[\s\S]*return null/)
|
||||
assert.match(createViewScript, /function isApplicationSubmitConfirmationText/)
|
||||
assert.match(createViewScript, /APPLICATION_SUBMIT_CONFIRM_TEXT_PATTERN[\s\S]*确认提交[\s\S]*提交审批/)
|
||||
assert.match(createViewScript, /function findPendingApplicationSubmitMessage/)
|
||||
assert.match(createViewScript, /normalizedPreview\.readyToSubmit/)
|
||||
assert.match(createViewScript, /async function handleApplicationSubmitConfirmationText/)
|
||||
assert.match(createViewScript, /await confirmApplicationSubmit\(\{ userText: rawText \}\)/)
|
||||
assert.match(createViewScript, /if \(await handleApplicationSubmitConfirmationText\(options\)\) \{[\s\S]*return null[\s\S]*\}[\s\S]*if \(isStewardSession\.value && !options\.skipStewardPlan/)
|
||||
assert.match(createViewScript, /message\.applicationSubmitConfirmed = true/)
|
||||
assert.match(createViewScript, /message\.applicationSubmitConfirmed[\s\S]*continue/)
|
||||
})
|
||||
|
||||
test('steward streaming uses chunked typewriter to reduce perceived latency', () => {
|
||||
assert.match(stewardPlanFlowScript, /STEWARD_TYPEWRITER_CHUNK_SIZE = 4/)
|
||||
assert.match(stewardPlanFlowScript, /STEWARD_THINKING_TYPEWRITER_CHUNK_SIZE = 5/)
|
||||
assert.match(stewardPlanFlowScript, /index = Math\.min\(total, index \+ STEWARD_TYPEWRITER_CHUNK_SIZE\)/)
|
||||
assert.match(stewardPlanFlowScript, /index = Math\.min\(chars\.length, index \+ STEWARD_THINKING_TYPEWRITER_CHUNK_SIZE\)/)
|
||||
assert.match(submitComposerScript, /STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE = 4/)
|
||||
assert.match(submitComposerScript, /STEWARD_DELEGATED_THINKING_CHUNK_SIZE = 5/)
|
||||
assert.match(submitComposerScript, /index = Math\.min\(chars\.length, index \+ STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE\)/)
|
||||
assert.match(submitComposerScript, /index = Math\.min\(chars\.length, index \+ STEWARD_DELEGATED_THINKING_CHUNK_SIZE\)/)
|
||||
assert.match(createViewScript, /STEWARD_FOLLOWUP_TYPEWRITER_CHUNK_SIZE = 4/)
|
||||
assert.match(createViewScript, /STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE = 5/)
|
||||
assert.match(createViewScript, /index = Math\.min\(chars\.length, index \+ STEWARD_FOLLOWUP_TYPEWRITER_CHUNK_SIZE\)/)
|
||||
assert.match(createViewScript, /index = Math\.min\(chars\.length, index \+ STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE\)/)
|
||||
})
|
||||
|
||||
test('steward initial workbench entry shows recognition state before messages arrive', () => {
|
||||
assert.match(createViewScript, /const hasStewardInitialAutoSubmitPayload = computed/)
|
||||
assert.match(createViewScript, /const showStewardInitialRecognition = computed/)
|
||||
assert.match(createViewScript, /!messages\.value\.length/)
|
||||
assert.match(createViewScript, /workbenchVisible\.value \|\| submitting\.value/)
|
||||
assert.match(createViewScript, /showStewardInitialRecognition/)
|
||||
assert.match(createViewTemplate, /v-if="showStewardInitialRecognition"/)
|
||||
assert.match(createViewTemplate, /class="steward-initial-recognition"/)
|
||||
assert.match(createViewTemplate, /小财管家正在识别意图/)
|
||||
})
|
||||
|
||||
test('steward application carry text does not leak transport examples into extraction', () => {
|
||||
const actions = buildStewardSuggestedActions({
|
||||
plan_id: 'steward-plan-transport-gap',
|
||||
plan_status: 'ready',
|
||||
tasks: [
|
||||
{
|
||||
task_id: 'task-application-beijing',
|
||||
task_type: 'expense_application',
|
||||
title: '北京出差申请',
|
||||
summary: '明天前往北京出差3天,支撑国网仿生产部署',
|
||||
assigned_agent: 'application_assistant',
|
||||
ontology_fields: {
|
||||
expense_type: 'travel',
|
||||
time_range: '2026-06-05 至 2026-06-07',
|
||||
location: '北京',
|
||||
reason: '支撑国网仿生产部署'
|
||||
},
|
||||
missing_fields: ['transport_mode', 'amount', 'attachments', 'employee_no']
|
||||
}
|
||||
],
|
||||
confirmation_groups: [
|
||||
{
|
||||
action_type: 'confirm_create_application',
|
||||
target_task_id: 'task-application-beijing'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const carryText = actions[0]?.payload?.carry_text || ''
|
||||
const currentTask = actions[0]?.payload?.steward_current_task || null
|
||||
assert.match(carryText, /费用类型:差旅/)
|
||||
assert.doesNotMatch(carryText, /费用类型:travel/)
|
||||
assert.match(carryText, /还需要补充:出行方式/)
|
||||
assert.match(carryText, /请先追问上述缺失信息/)
|
||||
assert.doesNotMatch(carryText, /高铁|火车|飞机|轮船|自驾|出租车/)
|
||||
assert.doesNotMatch(carryText, /预计金额|附件\/凭证|员工编号|金额/)
|
||||
assert.equal(currentTask?.task_type, 'expense_application')
|
||||
assert.deepEqual(currentTask?.missing_fields, ['transport_mode'])
|
||||
assert.deepEqual(
|
||||
filterStewardBlockingMissingFields(
|
||||
['transport_type', 'amount', 'attachments', 'employee_no', 'department_name'],
|
||||
'expense_application'
|
||||
),
|
||||
['transport_mode']
|
||||
)
|
||||
assert.deepEqual(
|
||||
filterStewardBlockingMissingFields(['amount', 'attachments'], 'reimbursement'),
|
||||
['amount', 'attachments']
|
||||
)
|
||||
|
||||
const preview = buildLocalApplicationPreview(carryText, { name: '曹笑竹', grade: 'P5' })
|
||||
assert.equal(preview.fields.transportMode, '')
|
||||
assert.equal(preview.missingFields.includes('出行方式'), true)
|
||||
|
||||
assert.match(stewardServiceScript, /fetchStewardSlotDecision/)
|
||||
assert.match(stewardServiceScript, /\/steward\/slot-decisions/)
|
||||
assert.match(submitComposerScript, /fetchStewardApplicationSlotDecision/)
|
||||
assert.match(submitComposerScript, /task_type:\s*'expense_application'/)
|
||||
assert.match(submitComposerScript, /steward_continuation:\s*continuation/)
|
||||
assert.match(createViewScript, /currentTask:\s*actionPayload\.steward_current_task/)
|
||||
})
|
||||
|
||||
test('steward application slot fallback ignores non-blocking application fields', () => {
|
||||
assert.match(submitComposerScript, /APPLICATION_NON_BLOCKING_ONTOLOGY_FIELDS/)
|
||||
assert.match(submitComposerScript, /'attachments'/)
|
||||
assert.match(submitComposerScript, /'employee_no'/)
|
||||
assert.match(submitComposerScript, /'amount'/)
|
||||
assert.match(submitComposerScript, /function formatStewardDecisionUserText/)
|
||||
assert.match(submitComposerScript, /formatStewardDecisionUserText\(decision\.question/)
|
||||
assert.match(submitComposerScript, /formatStewardDecisionUserText\(decision\.rationale/)
|
||||
assert.match(submitComposerScript, /normalizeTransportModeOption\(value \|\| label, ''\)/)
|
||||
assert.match(createViewScript, /normalizeTransportModeOption\(value, ''\)/)
|
||||
assert.equal(normalizeTransportModeOption('高铁', ''), '火车')
|
||||
assert.equal(normalizeTransportModeOption('自驾', ''), '')
|
||||
assert.match(submitComposerScript, /function resolveBlockingApplicationMissingFieldsForSteward/)
|
||||
assert.match(submitComposerScript, /isBlockingApplicationOntologyField\(key\)/)
|
||||
assert.match(submitComposerScript, /canonicalField && !isBlockingApplicationOntologyField\(canonicalField\)/)
|
||||
assert.doesNotMatch(submitComposerScript, /附件\/凭证和员工编号为合规必需字段/)
|
||||
})
|
||||
|
||||
test('flow panel durations use backend timing instead of local preview delay', () => {
|
||||
const flow = createFlowHarness()
|
||||
flow.resetFlowRun({ startedAt: Date.parse('2026-05-29T00:00:00.000Z') })
|
||||
@@ -750,6 +1049,42 @@ test('assistant markdown tables render with component-scoped table styling', ()
|
||||
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(th\),[\s\S]*\.message-answer-markdown :deep\(td\) \{[\s\S]*padding: 8px 10px;/)
|
||||
})
|
||||
|
||||
test('assistant reimbursement recognition copy renders structured markdown sections', () => {
|
||||
const rendered = renderMarkdown([
|
||||
'识别到您希望报销一笔“业务招待费”费用:',
|
||||
'',
|
||||
'基础信息识别结果:',
|
||||
'时间:2026-06-04',
|
||||
'事由:小财管家继续执行剩余任务,请填写报销单:客户接待费用报销。',
|
||||
'',
|
||||
'报销测算参考:',
|
||||
'先以用户填写金额或票据识别金额为基础,再结合费用类型、发生地点、业务事由和规则中心限额进行复核。'
|
||||
].join('\n'))
|
||||
|
||||
assert.match(rendered, /<h3>基础信息识别结果<\/h3>/)
|
||||
assert.match(rendered, /<li><strong>时间<\/strong>:2026-06-04<\/li>/)
|
||||
assert.match(rendered, /<li><strong>事由<\/strong>:小财管家继续执行剩余任务/)
|
||||
assert.match(rendered, /<h3>报销测算参考<\/h3>/)
|
||||
assert.doesNotMatch(rendered, /基础信息识别结果:<\/h3>/)
|
||||
})
|
||||
|
||||
test('application date overlap blocks steward preview before duplicate application table', () => {
|
||||
const existingRange = resolveApplicationDateRange('2026-06-05 至 2026-06-07')
|
||||
const currentRange = resolveApplicationDateRange('2026-06-06 至 2026-06-08')
|
||||
const disjointRange = resolveApplicationDateRange('2026-06-08 至 2026-06-10')
|
||||
|
||||
assert.equal(applicationDateRangesOverlap(currentRange, existingRange), true)
|
||||
assert.equal(applicationDateRangesOverlap(disjointRange, existingRange), false)
|
||||
assert.match(submitComposerScript, /function findOverlappingApplicationClaim\(applicationPreview, claimsPayload\)/)
|
||||
assert.match(submitComposerScript, /function normalizeApplicationExpenseType\(value\)/)
|
||||
assert.match(submitComposerScript, /currentExpenseType !== existingExpenseType/)
|
||||
assert.match(submitComposerScript, /fetchExpenseClaims\(\{ page: 1, pageSize: 100 \}\)/)
|
||||
assert.match(submitComposerScript, /buildApplicationDateConflictMessage\(applicationDateConflict\)/)
|
||||
assert.match(submitComposerScript, /meta: \[STEWARD_ASSISTANT_NAME, '申请日期冲突'\]/)
|
||||
assert.match(submitComposerScript, /applicationPreview: pauseForMissingFields \? null : applicationPreview/)
|
||||
assert.match(createViewScript, /actionType === 'open_application_detail'/)
|
||||
})
|
||||
|
||||
test('application preview merges rule center travel estimate into highlighted rows', () => {
|
||||
const preview = buildLocalApplicationPreview('申请 2026-05-25 至 2026-05-28 去上海出差3天,服务项目部署,火车,预计费用1800元', {
|
||||
name: '李文静',
|
||||
|
||||
@@ -17,6 +17,15 @@ test('isArchivedExpenseClaim recognizes finance archive stage', () => {
|
||||
claim_no: 'AP-20260525120000-ABCDEFGH',
|
||||
expense_type: 'travel_application'
|
||||
}),
|
||||
false
|
||||
)
|
||||
assert.equal(
|
||||
isArchivedExpenseClaim({
|
||||
status: 'approved',
|
||||
approval_stage: '申请归档',
|
||||
claim_no: 'AP-20260525120000-ABCDEFGH',
|
||||
expense_type: 'travel_application'
|
||||
}),
|
||||
true
|
||||
)
|
||||
assert.equal(
|
||||
|
||||
53
web/tests/list-dropdown-filter-style.test.mjs
Normal file
53
web/tests/list-dropdown-filter-style.test.mjs
Normal file
@@ -0,0 +1,53 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const root = process.cwd()
|
||||
|
||||
function readProjectFile(path) {
|
||||
return readFileSync(join(root, path), 'utf8')
|
||||
}
|
||||
|
||||
function testSharedDropdownStyleOwnsDocumentCenterPattern() {
|
||||
const sharedStyles = readProjectFile('web/src/assets/styles/components/document-list-shared.css')
|
||||
const documentStyles = readProjectFile('web/src/assets/styles/views/documents-center-view.css')
|
||||
const logsStyles = readProjectFile('web/src/assets/styles/views/logs-view.css')
|
||||
|
||||
assert.match(sharedStyles, /\.document-filter-menu\b/)
|
||||
assert.match(sharedStyles, /\.date-range-popover\b/)
|
||||
assert.match(sharedStyles, /\.status-filter-trigger\b/)
|
||||
assert.doesNotMatch(documentStyles, /\.document-filter-menu\b/)
|
||||
assert.doesNotMatch(logsStyles, /\.document-filter-menu\b[\s\S]*box-shadow/)
|
||||
}
|
||||
|
||||
function testListViewsUseSharedDropdownFilter() {
|
||||
const receiptView = readProjectFile('web/src/views/ReceiptFolderView.vue')
|
||||
const budgetView = readProjectFile('web/src/views/BudgetCenterView.vue')
|
||||
const budgetScript = readProjectFile('web/src/views/scripts/BudgetCenterView.js')
|
||||
const employeeView = readProjectFile('web/src/views/EmployeeManagementView.vue')
|
||||
const employeeScript = readProjectFile('web/src/views/scripts/EmployeeManagementView.js')
|
||||
const logsView = readProjectFile('web/src/views/LogsView.vue')
|
||||
const auditPicker = readProjectFile('web/src/components/audit/AuditPickerFilter.vue')
|
||||
const dropdown = readProjectFile('web/src/components/shared/DocumentDropdownFilter.vue')
|
||||
|
||||
assert.match(dropdown, /class="picker-filter document-filter"/)
|
||||
assert.match(dropdown, /class="picker-trigger filter-btn"/)
|
||||
assert.match(dropdown, /class="picker-popover document-filter-menu"/)
|
||||
assert.match(receiptView, /DocumentDropdownFilter/)
|
||||
assert.match(budgetView, /DocumentDropdownFilter/)
|
||||
assert.match(budgetScript, /DocumentDropdownFilter/)
|
||||
assert.doesNotMatch(budgetScript, /EnterpriseSelect/)
|
||||
assert.match(employeeView, /DocumentDropdownFilter/)
|
||||
assert.match(employeeScript, /DocumentDropdownFilter/)
|
||||
assert.match(logsView, /document-list-shared\.css/)
|
||||
assert.match(auditPicker, /document-filter-menu/)
|
||||
assert.doesNotMatch(auditPicker, /<header>/)
|
||||
}
|
||||
|
||||
function run() {
|
||||
testSharedDropdownStyleOwnsDocumentCenterPattern()
|
||||
testListViewsUseSharedDropdownFilter()
|
||||
console.log('list dropdown filter style tests passed')
|
||||
}
|
||||
|
||||
run()
|
||||
20
web/tests/notification-states-service.test.mjs
Normal file
20
web/tests/notification-states-service.test.mjs
Normal file
@@ -0,0 +1,20 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
NOTIFICATION_STATE_BATCH_SIZE,
|
||||
chunkNotificationStatePatches
|
||||
} from '../src/services/notificationStates.js'
|
||||
|
||||
test('notification state patches are split before posting to backend', () => {
|
||||
const patches = Array.from({ length: 205 }, (_, index) => ({
|
||||
notification_id: `document:owned:DOC-${index + 1}`,
|
||||
read: true
|
||||
}))
|
||||
const chunks = chunkNotificationStatePatches(patches)
|
||||
|
||||
assert.equal(NOTIFICATION_STATE_BATCH_SIZE, 100)
|
||||
assert.deepEqual(chunks.map((chunk) => chunk.length), [100, 100, 5])
|
||||
assert.equal(chunks[0][0].notification_id, 'document:owned:DOC-1')
|
||||
assert.equal(chunks[2][4].notification_id, 'document:owned:DOC-205')
|
||||
})
|
||||
27
web/tests/personal-workbench-compact-laptop.test.mjs
Normal file
27
web/tests/personal-workbench-compact-laptop.test.mjs
Normal file
@@ -0,0 +1,27 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const responsiveStyles = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/components/personal-workbench-responsive.css', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('personal workbench compacts hero input and capability cards on laptop screens', () => {
|
||||
assert.match(
|
||||
responsiveStyles,
|
||||
/@media \(min-width: 961px\) and \(max-width: 1440px\),\s*\n\s*\(min-width: 961px\) and \(max-height: 820px\)/
|
||||
)
|
||||
assert.match(responsiveStyles, /--hero-padding-top:\s*14px;/)
|
||||
assert.match(responsiveStyles, /--hero-padding-bottom:\s*14px;/)
|
||||
assert.match(responsiveStyles, /--hero-title-size:\s*24px;/)
|
||||
assert.match(responsiveStyles, /--composer-min-height:\s*92px;/)
|
||||
assert.match(responsiveStyles, /--composer-textarea-height:\s*38px;/)
|
||||
assert.match(responsiveStyles, /--capability-row-height:\s*82px;/)
|
||||
assert.match(responsiveStyles, /\.assistant-copy h1\s*\{[\s\S]*font-size:\s*var\(--hero-title-size\);/)
|
||||
assert.match(responsiveStyles, /\.assistant-composer\s*\{[\s\S]*padding:\s*var\(--composer-padding-block\) 14px 8px;/)
|
||||
assert.match(responsiveStyles, /\.quick-prompts button\s*\{[\s\S]*min-height:\s*24px;/)
|
||||
assert.match(responsiveStyles, /\.capability-card\s*\{[\s\S]*grid-template-columns:\s*34px minmax\(0,\s*1fr\) 14px;[\s\S]*padding:\s*12px 12px 12px 16px;/)
|
||||
assert.match(responsiveStyles, /@media \(max-width: 760px\)[\s\S]*\.workbench\s*\{[\s\S]*grid-template-rows:\s*none;/)
|
||||
})
|
||||
81
web/tests/receipt-folder-list-filters.test.mjs
Normal file
81
web/tests/receipt-folder-list-filters.test.mjs
Normal file
@@ -0,0 +1,81 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
RECEIPT_FILTER_ALL,
|
||||
applyReceiptListFilters,
|
||||
buildReceiptFilterControls,
|
||||
buildReceiptFilterTokens
|
||||
} from '../src/views/scripts/receiptFolderListFilters.js'
|
||||
|
||||
const rows = [
|
||||
{
|
||||
id: 'r-1',
|
||||
document_type: 'train_ticket',
|
||||
document_type_label: '火车票',
|
||||
scene_code: 'travel',
|
||||
scene_label: '差旅',
|
||||
document_date: '2026-05-02',
|
||||
avg_score: 0.96
|
||||
},
|
||||
{
|
||||
id: 'r-2',
|
||||
document_type: 'vat_invoice',
|
||||
document_type_label: '增值税发票',
|
||||
scene_code: 'office',
|
||||
scene_label: '办公',
|
||||
uploaded_at: '2026-04-18T10:00:00Z',
|
||||
avg_score: 0.82
|
||||
},
|
||||
{
|
||||
id: 'r-3',
|
||||
document_type: 'vat_invoice',
|
||||
document_type_label: '增值税发票',
|
||||
scene_code: 'travel',
|
||||
scene_label: '差旅',
|
||||
document_date: '',
|
||||
uploaded_at: '2026-05-20T08:00:00Z',
|
||||
avg_score: 0
|
||||
}
|
||||
]
|
||||
|
||||
function testBuildsDynamicOptions() {
|
||||
const controls = buildReceiptFilterControls(rows, {})
|
||||
const typeControl = controls.find((item) => item.key === 'documentType')
|
||||
const monthControl = controls.find((item) => item.key === 'month')
|
||||
|
||||
assert.equal(typeControl.options[0].value, RECEIPT_FILTER_ALL)
|
||||
assert.deepEqual(typeControl.options.map((item) => item.value).sort(), ['all', 'train_ticket', 'vat_invoice'])
|
||||
assert.deepEqual(monthControl.options.map((item) => item.value), ['all', '2026-05', '2026-04'])
|
||||
}
|
||||
|
||||
function testAppliesCombinedFilters() {
|
||||
const result = applyReceiptListFilters(rows, {
|
||||
documentType: 'vat_invoice',
|
||||
scene: 'travel',
|
||||
month: '2026-05',
|
||||
quality: 'missing'
|
||||
})
|
||||
|
||||
assert.deepEqual(result.map((item) => item.id), ['r-3'])
|
||||
}
|
||||
|
||||
function testBuildsReadableTokens() {
|
||||
const filters = {
|
||||
documentType: 'train_ticket',
|
||||
scene: 'travel',
|
||||
month: RECEIPT_FILTER_ALL,
|
||||
quality: 'high'
|
||||
}
|
||||
const tokens = buildReceiptFilterTokens(buildReceiptFilterControls(rows, filters), filters)
|
||||
|
||||
assert.deepEqual(tokens, ['票据类型:火车票', '费用场景:差旅', '置信度:高置信度'])
|
||||
}
|
||||
|
||||
function run() {
|
||||
testBuildsDynamicOptions()
|
||||
testAppliesCombinedFilters()
|
||||
testBuildsReadableTokens()
|
||||
console.log('receipt folder list filter tests passed')
|
||||
}
|
||||
|
||||
run()
|
||||
@@ -48,6 +48,12 @@ function testReceiptFolderViewSurface() {
|
||||
assert.match(view, /deleteCurrentReceipt/)
|
||||
assert.match(view, /ElCheckboxGroup/)
|
||||
assert.match(view, /fetchReceiptFolderItems\('all'\)/)
|
||||
assert.match(view, /DocumentDropdownFilter/)
|
||||
assert.match(view, /receiptFilterControls/)
|
||||
assert.match(view, /clear-filter-btn/)
|
||||
assert.match(view, /receiptFilters\[control\.key\]/)
|
||||
assert.match(view, /clearReceiptFilters/)
|
||||
assert.doesNotMatch(view, /class="filter-btn" type="button" @click="reloadReceipts"/)
|
||||
assert.match(view, /buildReceiptFile\(item\)/)
|
||||
assert.match(view, /source: selectedDraft \? 'detail' : 'receipt-folder'/)
|
||||
assert.match(view, /emit\('open-assistant'/)
|
||||
@@ -92,9 +98,13 @@ function testSharedDocumentListStyleReuse() {
|
||||
assert.match(sharedStyles, /\.table-wrap\b/)
|
||||
assert.match(sharedStyles, /\.doc-kind-tag\b/)
|
||||
assert.match(sharedStyles, /\.list-foot\b/)
|
||||
assert.match(sharedStyles, /\.clear-filter-btn\b/)
|
||||
assert.match(sharedStyles, /\.document-filter-menu\b/)
|
||||
assert.doesNotMatch(receiptStyles, /\.table-wrap\b/)
|
||||
assert.doesNotMatch(receiptStyles, /\.doc-kind-tag\b/)
|
||||
assert.doesNotMatch(receiptStyles, /\.list-foot\b/)
|
||||
assert.doesNotMatch(receiptStyles, /\.receipt-select-filter\b/)
|
||||
assert.doesNotMatch(receiptStyles, /\.receipt-clear-filters\b/)
|
||||
}
|
||||
|
||||
function testReceiptFolderDetailLayoutAdjustments() {
|
||||
|
||||
@@ -11,6 +11,8 @@ const CREATE_APPLICATION = '\u521b\u5efa\u7533\u8bf7'
|
||||
const DIRECT_MANAGER_APPROVAL = '\u76f4\u5c5e\u9886\u5bfc\u5ba1\u6279'
|
||||
const BUDGET_MANAGER_APPROVAL = '\u9884\u7b97\u7ba1\u7406\u8005\u5ba1\u6279'
|
||||
const APPROVAL_COMPLETED = '\u5ba1\u6279\u5b8c\u6210'
|
||||
const APPLICATION_LINK_STATUS = '\u5173\u8054\u5355\u636e\u72b6\u6001'
|
||||
const APPLICATION_ARCHIVE = '\u7533\u8bf7\u5f52\u6863'
|
||||
const RETURNED = '\u9000\u56de'
|
||||
const WAIT_SUBMIT = '\u5f85\u63d0\u4ea4'
|
||||
const LINKED_APPLICATION = '\u5173\u8054\u5355\u636e'
|
||||
@@ -156,7 +158,7 @@ test('application claims are mapped as application documents', () => {
|
||||
assert.equal(request.expenseTableSummary, '预计金额已随申请提交')
|
||||
assert.deepEqual(
|
||||
request.progressSteps.map((step) => step.label),
|
||||
[CREATE_APPLICATION, WAIT_LEADER_LI_APPROVAL, APPROVAL_COMPLETED]
|
||||
[CREATE_APPLICATION, WAIT_LEADER_LI_APPROVAL, APPROVAL_COMPLETED, APPLICATION_LINK_STATUS, ARCHIVED]
|
||||
)
|
||||
assert.equal(request.progressSteps.some((step) => step.label === 'AI预审'), false)
|
||||
assert.equal(request.progressSteps.some((step) => step.label === '财务审批'), false)
|
||||
@@ -250,7 +252,7 @@ test('application claims wait for department P8 budget monitor after leader appr
|
||||
|
||||
assert.deepEqual(
|
||||
request.progressSteps.map((step) => step.label),
|
||||
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, WAIT_BUDGET_ZHAO_APPROVAL, APPROVAL_COMPLETED]
|
||||
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, WAIT_BUDGET_ZHAO_APPROVAL, APPROVAL_COMPLETED, APPLICATION_LINK_STATUS, ARCHIVED]
|
||||
)
|
||||
assert.equal(request.progressSteps.find((step) => step.label === WAIT_BUDGET_ZHAO_APPROVAL)?.current, true)
|
||||
assert.equal(request.progressSteps.find((step) => step.label === DIRECT_MANAGER_APPROVAL)?.time, 'Leader Li通过')
|
||||
@@ -293,7 +295,7 @@ test('application budget wait label uses claim-level budget approver snapshot',
|
||||
assert.equal(request.budgetApproverName, 'P8 Executive')
|
||||
assert.deepEqual(
|
||||
request.progressSteps.map((step) => step.label),
|
||||
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, WAIT_BUDGET_P8_EXECUTIVE_APPROVAL, APPROVAL_COMPLETED]
|
||||
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, WAIT_BUDGET_P8_EXECUTIVE_APPROVAL, APPROVAL_COMPLETED, APPLICATION_LINK_STATUS, ARCHIVED]
|
||||
)
|
||||
assert.equal(request.progressSteps.find((step) => step.label === WAIT_BUDGET_P8_EXECUTIVE_APPROVAL)?.current, true)
|
||||
})
|
||||
@@ -386,14 +388,16 @@ test('approved application claims complete after budget approval', () => {
|
||||
})
|
||||
|
||||
assert.equal(request.documentTypeCode, 'application')
|
||||
assert.equal(request.workflowNode, '审批完成')
|
||||
assert.equal(request.workflowNode, APPLICATION_LINK_STATUS)
|
||||
assert.deepEqual(
|
||||
request.progressSteps.map((step) => step.label),
|
||||
['创建申请', '直属领导审批', '预算管理者审批', '审批完成']
|
||||
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, BUDGET_MANAGER_APPROVAL, APPROVAL_COMPLETED, APPLICATION_LINK_STATUS, ARCHIVED]
|
||||
)
|
||||
assert.equal(request.progressSteps.every((step) => step.done), true)
|
||||
assert.equal(request.progressSteps.find((step) => step.label === '直属领导审批')?.time, '李经理通过')
|
||||
assert.equal(request.progressSteps.find((step) => step.label === '预算管理者审批')?.time, '赵预算通过')
|
||||
assert.equal(request.progressSteps.find((step) => step.label === APPLICATION_LINK_STATUS)?.current, true)
|
||||
assert.equal(request.progressSteps.find((step) => step.label === APPLICATION_LINK_STATUS)?.time, '未关联')
|
||||
assert.equal(request.progressSteps.find((step) => step.label === ARCHIVED)?.done, false)
|
||||
assert.equal(request.progressSteps.find((step) => step.label === DIRECT_MANAGER_APPROVAL)?.time, '李经理通过')
|
||||
assert.equal(request.progressSteps.find((step) => step.label === BUDGET_MANAGER_APPROVAL)?.time, '赵预算通过')
|
||||
})
|
||||
|
||||
test('application claims hide budget step when leader approval also covers budget approval', () => {
|
||||
@@ -430,9 +434,10 @@ test('application claims hide budget step when leader approval also covers budge
|
||||
|
||||
assert.deepEqual(
|
||||
request.progressSteps.map((step) => step.label),
|
||||
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, APPROVAL_COMPLETED]
|
||||
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, APPROVAL_COMPLETED, APPLICATION_LINK_STATUS, ARCHIVED]
|
||||
)
|
||||
assert.equal(request.progressSteps.every((step) => step.done), true)
|
||||
assert.equal(request.progressSteps.find((step) => step.label === APPLICATION_LINK_STATUS)?.current, true)
|
||||
assert.equal(request.progressSteps.find((step) => step.label === ARCHIVED)?.done, false)
|
||||
assert.equal(request.progressSteps.some((step) => step.label === BUDGET_MANAGER_APPROVAL), false)
|
||||
assert.equal(request.progressSteps.find((step) => step.label === DIRECT_MANAGER_APPROVAL)?.time, '李预算经理通过')
|
||||
})
|
||||
@@ -481,13 +486,92 @@ test('approved application claims hide budget step when dynamic route skipped bu
|
||||
|
||||
assert.deepEqual(
|
||||
request.progressSteps.map((step) => step.label),
|
||||
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, APPROVAL_COMPLETED]
|
||||
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, APPROVAL_COMPLETED, APPLICATION_LINK_STATUS, ARCHIVED]
|
||||
)
|
||||
assert.equal(request.progressSteps.every((step) => step.done), true)
|
||||
assert.equal(request.progressSteps.find((step) => step.label === APPLICATION_LINK_STATUS)?.current, true)
|
||||
assert.equal(request.progressSteps.find((step) => step.label === ARCHIVED)?.done, false)
|
||||
assert.equal(request.progressSteps.some((step) => step.label === BUDGET_MANAGER_APPROVAL), false)
|
||||
assert.equal(request.progressSteps.find((step) => step.label === DIRECT_MANAGER_APPROVAL)?.time, 'Leader Li通过')
|
||||
})
|
||||
|
||||
test('approved application claims show linked reimbursement status before archive', () => {
|
||||
const request = mapExpenseClaimToRequest({
|
||||
id: 'claim-application-linked-draft',
|
||||
claim_no: 'AP-202606050001-LINKED',
|
||||
employee_name: '张三',
|
||||
department_name: '交付部',
|
||||
manager_name: 'Leader Li',
|
||||
expense_type: 'travel_application',
|
||||
reason: 'Project onsite support',
|
||||
location: 'Shanghai',
|
||||
amount: 500,
|
||||
invoice_count: 0,
|
||||
occurred_at: '2026-06-05T00:00:00.000Z',
|
||||
submitted_at: '2026-06-05T02:00:00.000Z',
|
||||
created_at: '2026-06-05T01:30:00.000Z',
|
||||
updated_at: '2026-06-05T03:00:00.000Z',
|
||||
status: 'approved',
|
||||
approval_stage: APPLICATION_LINK_STATUS,
|
||||
risk_flags_json: [
|
||||
{
|
||||
source: 'manual_approval',
|
||||
event_type: 'expense_application_approval',
|
||||
operator: 'Leader Li',
|
||||
previous_approval_stage: DIRECT_MANAGER_APPROVAL,
|
||||
next_approval_stage: APPLICATION_LINK_STATUS,
|
||||
generated_draft_claim_no: 'RE-202606050001-LINKED',
|
||||
created_at: '2026-06-05T03:00:00.000Z'
|
||||
}
|
||||
],
|
||||
items: []
|
||||
})
|
||||
|
||||
const linkStep = request.progressSteps.find((step) => step.label === APPLICATION_LINK_STATUS)
|
||||
assert.equal(request.workflowNode, APPLICATION_LINK_STATUS)
|
||||
assert.equal(linkStep?.current, true)
|
||||
assert.equal(linkStep?.time, '关联中 RE-202606050001-LINKED')
|
||||
assert.equal(request.secondaryStatusValue, '关联中 RE-202606050001-LINKED')
|
||||
assert.equal(request.progressSteps.find((step) => step.label === ARCHIVED)?.done, false)
|
||||
})
|
||||
|
||||
test('application claims are archived only after linked reimbursement is paid', () => {
|
||||
const request = mapExpenseClaimToRequest({
|
||||
id: 'claim-application-archived',
|
||||
claim_no: 'AP-202606050001-ARCHIVED',
|
||||
employee_name: '张三',
|
||||
department_name: '交付部',
|
||||
manager_name: 'Leader Li',
|
||||
expense_type: 'travel_application',
|
||||
reason: 'Project onsite support',
|
||||
location: 'Shanghai',
|
||||
amount: 500,
|
||||
invoice_count: 0,
|
||||
occurred_at: '2026-06-05T00:00:00.000Z',
|
||||
submitted_at: '2026-06-05T02:00:00.000Z',
|
||||
created_at: '2026-06-05T01:30:00.000Z',
|
||||
updated_at: '2026-06-07T03:00:00.000Z',
|
||||
status: 'approved',
|
||||
approval_stage: APPLICATION_ARCHIVE,
|
||||
risk_flags_json: [
|
||||
{
|
||||
source: 'application_archive_sync',
|
||||
event_type: 'expense_application_archived_by_reimbursement',
|
||||
reimbursement_claim_no: 'RE-202606050001-ARCHIVED',
|
||||
created_at: '2026-06-07T03:00:00.000Z'
|
||||
}
|
||||
],
|
||||
items: []
|
||||
})
|
||||
|
||||
assert.equal(request.workflowNode, APPLICATION_ARCHIVE)
|
||||
assert.deepEqual(
|
||||
request.progressSteps.map((step) => step.label),
|
||||
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, APPROVAL_COMPLETED, APPLICATION_LINK_STATUS, ARCHIVED]
|
||||
)
|
||||
assert.equal(request.progressSteps.every((step) => step.done), true)
|
||||
assert.equal(request.secondaryStatusValue, '已归档')
|
||||
})
|
||||
|
||||
test('progress steps show approval operator time and current stay duration', () => {
|
||||
const originalNow = Date.now
|
||||
Date.now = () => new Date('2026-05-20T05:00:00.000Z').getTime()
|
||||
|
||||
@@ -63,6 +63,7 @@ test('sidebar no longer renders document center unread indicators', () => {
|
||||
test('topbar bell owns document center unread notifications', () => {
|
||||
assert.match(topbar, /useDocumentCenterInbox/)
|
||||
assert.match(topbar, /useTopBarNotificationStates/)
|
||||
assert.match(topbar, /resolveDocumentNotificationId/)
|
||||
assert.match(topbar, /notificationRows: documentInboxNotificationRows/)
|
||||
assert.match(topbar, /const documentNotificationItems = computed/)
|
||||
assert.match(topbar, /title: `\$\{row\.documentTypeLabel \|\| '单据'\} \$\{row\.documentNo \|\| row\.claimId \|\| '待生成'\}`/)
|
||||
@@ -109,6 +110,8 @@ test('topbar notification state is persisted through backend API with local fall
|
||||
|
||||
test('document inbox reuses document center viewed-key state', () => {
|
||||
assert.match(documentInbox, /DOCUMENT_VIEWED_KEYS_CHANGE_EVENT/)
|
||||
assert.match(documentInbox, /fetchNotificationStates/)
|
||||
assert.match(documentInbox, /mergeNotificationStatesIntoViewedDocumentKeys/)
|
||||
assert.match(documentInbox, /readViewedDocumentKeys/)
|
||||
assert.match(documentInbox, /countNewDocuments\(documentRows\.value, viewedDocumentKeys\.value\)/)
|
||||
assert.match(documentInbox, /const notificationRows = computed/)
|
||||
@@ -120,5 +123,7 @@ test('document inbox reuses document center viewed-key state', () => {
|
||||
assert.match(documentInbox, /fetchArchivedExpenseClaims/)
|
||||
assert.match(documentInbox, /window\.addEventListener\(DOCUMENT_VIEWED_KEYS_CHANGE_EVENT, refreshViewedDocumentKeys\)/)
|
||||
assert.match(documentNewState, /export const DOCUMENT_VIEWED_KEYS_CHANGE_EVENT/)
|
||||
assert.match(documentNewState, /resolveDocumentNotificationId/)
|
||||
assert.match(documentNewState, /buildDocumentsViewedStatePatches/)
|
||||
assert.match(documentNewState, /window\.dispatchEvent\(new CustomEvent\(DOCUMENT_VIEWED_KEYS_CHANGE_EVENT\)\)/)
|
||||
})
|
||||
|
||||
23
web/tests/topbar-compact-laptop.test.mjs
Normal file
23
web/tests/topbar-compact-laptop.test.mjs
Normal file
@@ -0,0 +1,23 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const topbarStyles = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/components/top-bar.css', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('topbar uses a compact laptop layout without overriding mobile layout', () => {
|
||||
assert.match(
|
||||
topbarStyles,
|
||||
/@media \(min-width: 961px\) and \(max-width: 1440px\),\s*\n\s*\(min-width: 961px\) and \(max-height: 820px\)/
|
||||
)
|
||||
assert.match(topbarStyles, /\.topbar\s*\{[\s\S]*padding:\s*12px 20px 14px;/)
|
||||
assert.match(topbarStyles, /\.topbar h1\s*\{[\s\S]*font-size:\s*22px;/)
|
||||
assert.match(topbarStyles, /\.topbar p\s*\{[\s\S]*-webkit-line-clamp:\s*1;/)
|
||||
assert.match(topbarStyles, /\.range-shell\s*\{[\s\S]*height:\s*36px;/)
|
||||
assert.match(topbarStyles, /\.dashboard-switch-select :deep\(\.el-select__wrapper\)\s*\{[\s\S]*min-height:\s*38px;/)
|
||||
assert.match(topbarStyles, /\.topbar-icon-btn\s*\{[\s\S]*width:\s*30px;[\s\S]*height:\s*30px;/)
|
||||
assert.match(topbarStyles, /@media \(max-width: 960px\)[\s\S]*\.topbar\s*\{[\s\S]*flex-direction:\s*column;/)
|
||||
})
|
||||
@@ -118,6 +118,21 @@ test('document review drawer fills sidebar height and preview dialog is centered
|
||||
assert.match(createViewPart4Styles, /\.review-preview-modal\s*\{[\s\S]*margin:\s*auto;[\s\S]*flex:\s*none;/)
|
||||
})
|
||||
|
||||
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%;/)
|
||||
})
|
||||
|
||||
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;/)
|
||||
@@ -619,7 +634,10 @@ test('guided save draft emits refresh and exposes reimbursement draft detail car
|
||||
/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(messageItemTemplate, /v-if="ui\.canOpenDraftDetail\(message\)"/)
|
||||
assert.match(messageItemTemplate, /class="reimbursement-draft-link"/)
|
||||
assert.match(messageItemTemplate, /reimbursement-draft-pending-detail/)
|
||||
})
|
||||
|
||||
@@ -87,3 +87,52 @@ test('workbench summary builds real user notifications and progress from request
|
||||
assert.ok(summary.expenseStatsDetail.operationRows.some((item) => item.source === '待办'))
|
||||
assert.ok(summary.expenseStatsDetail.operationRows.some((item) => item.source === '进度'))
|
||||
})
|
||||
|
||||
test('workbench progress keeps application document type for AP claims', () => {
|
||||
const summary = buildWorkbenchSummary(
|
||||
[
|
||||
{
|
||||
id: 'AP-202606050001-ABCDEFGH',
|
||||
claimId: 'application-1',
|
||||
claimNo: 'AP-202606050001-ABCDEFGH',
|
||||
person: currentUser.name,
|
||||
title: '差旅费用',
|
||||
approvalKey: 'in_progress',
|
||||
approvalStatus: '直属领导审批',
|
||||
amount: 1880,
|
||||
createdAt: '2026-06-05T09:00:00+08:00',
|
||||
updatedAt: '2026-06-05T09:10:00+08:00',
|
||||
progressSteps: [
|
||||
buildStep('创建申请', 0, 1),
|
||||
buildStep('直属领导审批', 1, 1),
|
||||
buildStep('归档', 2, 1)
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'REQ-APPLICATION-001',
|
||||
claimId: 'application-2',
|
||||
claimNo: 'REQ-APPLICATION-001',
|
||||
documentTypeCode: 'application',
|
||||
documentTypeLabel: '报销单',
|
||||
person: currentUser.name,
|
||||
title: '办公用品采购',
|
||||
approvalKey: 'in_progress',
|
||||
approvalStatus: '直属领导审批',
|
||||
amount: 2600,
|
||||
createdAt: '2026-06-05T09:05:00+08:00',
|
||||
updatedAt: '2026-06-05T09:15:00+08:00',
|
||||
progressSteps: [
|
||||
buildStep('创建申请', 0, 1),
|
||||
buildStep('直属领导审批', 1, 1),
|
||||
buildStep('归档', 2, 1)
|
||||
]
|
||||
}
|
||||
],
|
||||
currentUser
|
||||
)
|
||||
|
||||
assert.deepEqual(
|
||||
summary.progressItems.map((item) => item.documentTypeLabel),
|
||||
['申请单', '申请单']
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user