import assert from 'node:assert/strict' import { execFileSync } from 'node:child_process' import { readdirSync, readFileSync, statSync } from 'node:fs' import test from 'node:test' import { fileURLToPath } from 'node:url' function readSource(path) { try { return readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8') } catch { return '' } } function readRuleBody(source, selector) { const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') const match = source.match(new RegExp(`${escapedSelector}\\s*\\{([\\s\\S]*?)\\}`)) return match?.[1] || '' } function countGifFrameBlocks(buffer) { let count = 0 for (let index = 0; index < buffer.length - 2; index += 1) { if (buffer[index] === 0x21 && buffer[index + 1] === 0xf9 && buffer[index + 2] === 0x04) { count += 1 } } return count } function readAssetFrames(assetPath, options = {}) { const metadata = execFileSync('identify', ['-format', '%w %h %n\n', assetPath], { encoding: 'utf8' }).trim().split('\n')[0].split(/\s+/).map(Number) const sourceWidth = metadata[0] const sourceHeight = metadata[1] const frameCount = metadata[2] || 1 const width = options.width || sourceWidth const height = options.height || sourceHeight const convertArgs = [assetPath, '-coalesce'] if (options.width || options.height) { convertArgs.push('-resize', `${width}x${height}!`) } convertArgs.push('-alpha', 'off', '-depth', '8', 'rgb:-') const pixels = execFileSync('convert', convertArgs, { maxBuffer: 64 * 1024 * 1024 }) const frameSize = width * height * 3 return { frameCount: Math.min(frameCount, Math.floor(pixels.length / frameSize)), frameSize, height, pixels, width } } function measureGifMotion(assetPath) { const { frameCount, frameSize, pixels } = readAssetFrames(assetPath, { width: 64, height: 64 }) const delta = (leftFrame, rightFrame) => { const leftOffset = leftFrame * frameSize const rightOffset = rightFrame * frameSize let total = 0 for (let index = 0; index < frameSize; index += 1) { total += Math.abs(pixels[leftOffset + index] - pixels[rightOffset + index]) } return total / frameSize } const adjacent = Array.from( { length: Math.max(0, frameCount - 1) }, (_, index) => delta(index, index + 1) ).sort((left, right) => left - right) return { medianAdjacentDelta: adjacent[Math.floor(adjacent.length / 2)] || 0, seamDelta: delta(frameCount - 1, 0) } } function measureGifDuration(assetPath) { const delays = execFileSync('identify', ['-format', '%T\n', assetPath], { encoding: 'utf8' }).trim().split('\n').map(Number) return delays.reduce((total, delay) => total + delay * 10, 0) } function measureOrbAssetPresentation(assetPath) { const { frameCount, frameSize, height, pixels, width } = readAssetFrames(assetPath) let minimumCornerLuma = 255 let maximumCornerLuma = 0 let minimumBackgroundSimilarityRatio = 1 let minimumForegroundWidthRatio = 1 let minimumForegroundHeightRatio = 1 for (let frame = 0; frame < frameCount; frame += 1) { const frameOffset = frame * frameSize const pixelAt = (x, y) => { const offset = frameOffset + (y * width + x) * 3 return [pixels[offset], pixels[offset + 1], pixels[offset + 2]] } const corners = [ pixelAt(0, 0), pixelAt(width - 1, 0), pixelAt(0, height - 1), pixelAt(width - 1, height - 1) ] const cornerLumas = corners.map((pixel) => (pixel[0] + pixel[1] + pixel[2]) / 3) minimumCornerLuma = Math.min(minimumCornerLuma, ...cornerLumas) maximumCornerLuma = Math.max(maximumCornerLuma, ...cornerLumas) const background = [0, 1, 2].map((channel) => Math.round( corners.reduce((total, pixel) => total + pixel[channel], 0) / corners.length )) let backgroundSimilarity = 0 let minX = width let minY = height let maxX = -1 let maxY = -1 for (let y = 0; y < height; y += 1) { for (let x = 0; x < width; x += 1) { const pixel = pixelAt(x, y) const diff = pixel.reduce( (total, value, channel) => total + Math.abs(value - background[channel]), 0 ) if (diff > 22) { minX = Math.min(minX, x) minY = Math.min(minY, y) maxX = Math.max(maxX, x) maxY = Math.max(maxY, y) } if (diff <= 12) { backgroundSimilarity += 1 } } } if (maxX >= minX && maxY >= minY) { minimumForegroundWidthRatio = Math.min(minimumForegroundWidthRatio, (maxX - minX + 1) / width) minimumForegroundHeightRatio = Math.min(minimumForegroundHeightRatio, (maxY - minY + 1) / height) } minimumBackgroundSimilarityRatio = Math.min( minimumBackgroundSimilarityRatio, backgroundSimilarity / (width * height) ) } return { minimumCornerLuma, maximumCornerLuma, minimumBackgroundSimilarityRatio, minimumForegroundWidthRatio, minimumForegroundHeightRatio, width, height } } const appShell = readSource('../src/views/AppShellRouteView.vue') const workbenchView = readSource('../src/views/PersonalWorkbenchView.vue') const aiMode = readSource('../src/components/business/PersonalWorkbenchAiMode.vue') const aiModeTemplate = readSource('../src/components/business/PersonalWorkbenchAiMode.template.html') const aiModeComposer = readSource('../src/components/business/workbench-ai/WorkbenchAiComposer.vue') const aiModeFileStrip = readSource('../src/components/business/workbench-ai/WorkbenchAiFileStrip.vue') const aiModeRuntimeDir = fileURLToPath(new URL('../src/composables/workbenchAiMode/', import.meta.url)) const aiModeRuntime = readdirSync(aiModeRuntimeDir) .filter((file) => file.endsWith('.js')) .sort() .map((file) => readFileSync(new URL(`../src/composables/workbenchAiMode/${file}`, import.meta.url), 'utf8')) .join('\n') const aiModeSurface = `${aiMode}\n${aiModeTemplate}\n${aiModeComposer}\n${aiModeFileStrip}\n${aiModeRuntime}` const aiModeStyles = readSource('../src/assets/styles/components/personal-workbench-ai-mode.css') const workbenchViewStyles = readSource('../src/assets/styles/views/personal-workbench-view.css') const appStyles = readSource('../src/assets/styles/app.css') const aiBackgroundRule = readRuleBody(aiModeStyles, '.workbench-ai-mode::after') const orbRule = readRuleBody(aiModeStyles, '.workbench-ai-orb') const orbImageRule = readRuleBody(aiModeStyles, '.workbench-ai-orb__image') const composerRule = readRuleBody(aiModeStyles, '.workbench-ai-composer') const composerTextareaRule = readRuleBody(aiModeStyles, '.workbench-ai-composer textarea') const fileStripRule = readRuleBody(aiModeStyles, '.workbench-ai-file-strip') const fileCardRule = readRuleBody(aiModeStyles, '.workbench-ai-file-card') const orbIconAsset = fileURLToPath( new URL('../src/assets/workbench-ai-mode-orb-icon.gif', import.meta.url) ) const orbIconPngAsset = fileURLToPath( new URL('../src/assets/workbench-ai-mode-orb-icon.png', import.meta.url) ) const orbIconBuffer = readFileSync(orbIconAsset) test('app shell owns the workbench mode and wires it through topbar and content', () => { assert.match(appShell, /function resolveDefaultWorkbenchMode\(user\)\s*\{[\s\S]*isPlatformAdminUser\(user\)[\s\S]*'traditional'[\s\S]*'ai'/) assert.match(appShell, /const workbenchMode = ref\(resolveDefaultWorkbenchMode\(currentUser\.value\)\)/) assert.doesNotMatch(appShell, /const workbenchMode = ref\('traditional'\)/) assert.match(appShell, /watch\(\s*\(\) => currentUser\.value,[\s\S]*resolveDefaultWorkbenchMode\(user\)/) assert.match(appShell, /function toggleWorkbenchMode\(\)/) assert.match(appShell, /const nextMode = workbenchMode\.value === 'ai' \? 'traditional' : 'ai'/) assert.match(appShell, /sidebarCollapsedBeforeAiMode\.value = sidebarCollapsed\.value/) assert.match(appShell, /workbenchMode\.value = nextMode/) assert.match(appShell, /sidebarCollapsed\.value = sidebarCollapsedBeforeAiMode\.value/) assert.match(appShell, / workbenchMode\.value === 'ai'\)/) assert.match(appShell, /const isWorkbenchAiMode = computed\(\(\) => activeView\.value === 'workbench' && workbenchMode\.value === 'ai'\)/) assert.match(appShell, /'workbench-ai-sidebar-active': isAiShellMode/) assert.match(appShell, /'workbench-workarea-ai-mode': isWorkbenchAiMode/) assert.match(appStyles, /\.workarea\.workbench-workarea\.workbench-workarea-ai-mode\s*\{[\s\S]*padding:\s*0;[\s\S]*background:\s*transparent;/) }) test('personal workbench view swaps the traditional dashboard with the AI mode screen', () => { assert.match(workbenchView, /import PersonalWorkbenchAiMode from '\.\.\/components\/business\/PersonalWorkbenchAiMode\.vue'/) assert.match(workbenchView, / { assert.match(aiModeSurface, /personal-workbench-ai-mode\.css/) assert.doesNotMatch(aiModeSurface, /workbench-ai-mode-robot-bg\.png/) assert.match(aiModeSurface, /workbench-ai-mode-orb-icon\.gif/) assert.match(aiModeSurface, / isLikelyReceiptAssociationFile\(file\)\)/) assert.match(aiModeSurface, /!shouldKeepAiAttachmentInAssistantReply\(prompt\)/) assert.match(aiModeSurface, /const aiAttachmentAssociationRuntime = new Map\(\)/) assert.match(aiModeSurface, /function findAiAttachmentAssociationRuntime\(options = \{\}\)/) assert.match(aiModeSurface, /resolveAiAttachmentAssociationClaimNo\(actionPayload\)/) assert.match(aiModeSurface, /if \(actionType === AI_ATTACHMENT_OCR_DETAIL_ACTION\)/) assert.match(aiModeSurface, /const collected = await collectAiModeReceiptContext\(files\)/) assert.match(aiModeSurface, /function extractReceiptIdsFromOcrDocuments\(documents = \[\]\)/) assert.match(aiModeSurface, /const receiptIds = attachmentJobFlow\.extractReceiptIdsFromOcrDocuments\(collected\.ocrDocuments\)/) assert.match(aiModeSurface, /await createAttachmentAssociationJob\(\{[\s\S]*receipt_ids: receiptIds,[\s\S]*conversation_id: conversationId\?\.value/) assert.match(aiModeSurface, /attachmentAssociationJob: job/) assert.match(aiModeSurface, /async function pollJob\(/) assert.match(aiModeSurface, /fetchAttachmentAssociationJob\(normalizedJobId\)/) assert.match(aiModeSurface, /function resumePendingJobs\(\)/) assert.match(aiModeSurface, /resumePendingAiAttachmentAssociationJobs: attachmentJobFlow\.resumePendingJobs/) assert.match(aiModeSurface, /attachmentFlow\.resumePendingAiAttachmentAssociationJobs\(\)/) assert.match(aiModeSurface, /attachmentAssociationJob: normalizeInlineAttachmentAssociationJob/) assert.match(aiModeSurface, /async function confirmAiAttachmentAssociation\(actionPayload = \{\}, sourceMessage = null\)/) assert.match(aiModeSurface, /syncExpenseClaimFilesToDraft\(\{[\s\S]*fetchExpenseClaimDetail,[\s\S]*createExpenseClaimItem,[\s\S]*uploadExpenseClaimItemAttachment/) assert.match(aiModeSurface, /if \(actionType === AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION\)/) assert.match(aiModeSurface, /if \(shouldRunAiAttachmentAutoAssociation\(entry, files, cleanPrompt\)\) \{[\s\S]*requestAiAttachmentAssociationReply\(cleanPrompt, entry, files\)/) assert.match(aiModeSurface, /const fileMergeResult = mergeFilesWithLimit\(selectedFiles\.value, Array\.from\(event\.target\.files \|\| \[\]\), MAX_ATTACHMENTS\)/) assert.match(aiModeSurface, /selectedFiles\.value = fileMergeResult\.files/) assert.doesNotMatch(aiModeSurface, /selectedFiles\.value = Array\.from\(event\.target\.files \|\| \[\]\)\.slice\(0, 10\)/) assert.doesNotMatch(aiModeSurface, /已选择 \{\{ selectedFiles\.length \}\} 份附件/) assert.match(aiModeSurface, /Axiom Ultra 3\.1/) assert.match(aiModeSurface, /mdi mdi-calendar-range/) assert.match(aiModeSurface, /workbench-ai-date-popover/) assert.match(aiModeSurface, /type="date"/) assert.match(aiModeSurface, /:min="resolveInlineApplicationPreviewEditorDateMin\(message, row\.key\)"/) assert.match(aiModeSurface, /:max="resolveInlineApplicationPreviewEditorDateMax\(message, row\.key\)"/) assert.match(aiModeSurface, /resolveInlineApplicationPreviewEditorControl\(row\.key\) === 'date'/) assert.match(aiModeSurface, /class="\['application-preview-input', 'application-preview-date-input', `application-preview-input--\$\{row\.key\}`\]"/) assert.match(aiModeSurface, /function resolveInlineApplicationPreviewEditorControl\(fieldKey\) \{[\s\S]*return resolveApplicationPreviewEditorControl\(fieldKey\)/) assert.match(aiModeSurface, /function resolveInlineApplicationPreviewEditorDateMin\(message, fieldKey\) \{[\s\S]*return resolveApplicationPreviewEditorDateMin\?\.\(message, fieldKey\) \|\| ''/) assert.match(aiModeSurface, /function resolveInlineApplicationPreviewEditorDateMax\(message, fieldKey\) \{[\s\S]*return resolveApplicationPreviewEditorDateMax\?\.\(message, fieldKey\) \|\| ''/) assert.doesNotMatch(aiModeSurface, /return control === 'date' \? 'text' : control/) assert.doesNotMatch(aiModeSurface, /mdi mdi-web/) assert.match(aiModeSurface, /mdi mdi-microphone-outline/) assert.match(aiModeSurface, /mdi mdi-arrow-up/) assert.match(aiModeSurface, /快速开始/) assert.match(aiModeSurface, /action-icon-wrapper/) assert.match(aiModeSurface, /发起报销/) assert.match(aiModeSurface, /查询预算/) assert.match(aiModeSurface, /解释制度/) assert.match(aiModeSurface, /催办审批/) assert.match(aiModeSurface, //) assert.match(aiModeSurface, /@submit\.prevent="runtime\.submitAiModePrompt"/) assert.equal((aiModeSurface.match(/ \{[\s\S]*forceInlineConversationToBottom\(\)[\s\S]*\}\)/) assert.match(aiModeSurface, /window\.setTimeout\(\(\) => \{[\s\S]*if \(inlineConversationAutoScrollPinned\.value\) \{[\s\S]*forceInlineConversationToBottom\(\)[\s\S]*\}[\s\S]*\}, INLINE_LAYOUT_SETTLE_SCROLL_DELAY_MS\)/) assert.match(aiModeSurface, /const shouldAutoScroll = inlineConversationAutoScrollPinned\.value[\s\S]*updateInlineMessageContent\(message, streamedContent\)[\s\S]*scrollInlineConversationToBottom\(\{ force: shouldAutoScroll \}\)/) assert.match(aiModeSurface, /const shouldAutoScroll = inlineConversationAutoScrollPinned\.value[\s\S]*appendInlineMessageContent\(message, data\.delta \|\| data\.content \|\| data\.text \|\| ''\)[\s\S]*scrollInlineConversationToBottom\(\{ force: shouldAutoScroll \}\)/) assert.match(aiModeSurface, /inlineConversationAutoScrollPinned\.value = true[\s\S]*conversationMessages\.value\.push\(createInlineMessage\('user', cleanPrompt\)\)/) assert.match(aiModeSurface, /function openInlineRecentConversation\(item = \{\}\) \{[\s\S]*inlineConversationAutoScrollPinned\.value = true[\s\S]*conversationMessages\.value =/) assert.doesNotMatch(aiModeSurface, /scrollTo\(\{ top: el\.scrollHeight, behavior: 'smooth' \}\)/) assert.match(aiModeStyles, /\.workbench-ai-thread\s*\{[\s\S]*display:\s*flex;[\s\S]*flex-direction:\s*column;[\s\S]*overflow-y:\s*auto;[\s\S]*scrollbar-width:\s*none;/) assert.match(aiModeStyles, /\.workbench-ai-thread\s*>\s*:first-child\s*\{[\s\S]*margin-top:\s*auto;/) assert.match(aiModeStyles, /\.workbench-ai-message\s*\{[\s\S]*flex:\s*0 0 auto;/) assert.match(aiModeStyles, /\.workbench-ai-empty-thread\s*\{[\s\S]*flex:\s*0 0 auto;/) assert.doesNotMatch(aiModeStyles, /align-content:\s*end;/) assert.doesNotMatch(aiModeStyles, /\.workbench-ai-thread\s*\{[\s\S]*scroll-behavior:\s*smooth;/) assert.match(aiModeStyles, /\.workbench-ai-thread::-webkit-scrollbar\s*\{[\s\S]*display:\s*none;/) assert.match(aiModeStyles, /\.workbench-ai-conversation-bottom\s*\{[\s\S]*position:\s*relative;[\s\S]*z-index:\s*6;/) assert.doesNotMatch(aiModeStyles, /\.workbench-ai-conversation-bottom\s*\{[\s\S]*position:\s*sticky;/) assert.doesNotMatch(aiModeStyles, /\.workbench-ai-conversation-bottom\s*\{[\s\S]*bottom:\s*0;/) assert.match(aiModeStyles, /\.workbench-ai-conversation-bottom::before\s*\{[\s\S]*display:\s*none;/) assert.match(aiModeStyles, /\.workbench-ai-thinking-panel\s*\{[\s\S]*display:\s*grid;[\s\S]*border:\s*1px solid rgba\(191,\s*219,\s*254,\s*0\.58\);/) assert.match(aiModeStyles, /\.workbench-ai-thinking-toggle\s*\{[\s\S]*border:\s*0;[\s\S]*background:\s*transparent;/) assert.match(aiModeStyles, /\.workbench-ai-thinking-list\s*\{[\s\S]*border:\s*0;[\s\S]*background:\s*transparent;[\s\S]*overflow:\s*visible;/) assert.match(aiModeStyles, /\.workbench-ai-thinking-item\s*\{[\s\S]*grid-template-columns:\s*18px minmax\(0,\s*1fr\);/) assert.match(aiModeStyles, /\.workbench-ai-thinking-dot\s*\{[\s\S]*justify-self:\s*center;/) assert.doesNotMatch(aiModeStyles, /\.workbench-ai-thinking-collapse-btn:disabled/) assert.match(aiModeStyles, /\.workbench-ai-thinking-collapse-enter-active,[\s\S]*\.workbench-ai-thinking-collapse-leave-active\s*\{[\s\S]*max-height 220ms ease/) assert.match(aiModeStyles, /\.workbench-ai-confirm-dialog\s*\{[\s\S]*border-radius:\s*18px;/) assert.match(aiModeStyles, /\.workbench-ai-answer-card\s*\{[\s\S]*box-shadow:\s*none;[\s\S]*backdrop-filter:\s*none;/) assert.match(aiModeStyles, /\.workbench-ai-answer-markdown\s*\{[\s\S]*line-height:\s*1\.86;/) assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(h3\)\s*\{[\s\S]*font-size:\s*21px;/) assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-focus-grid\)\s*\{[\s\S]*border-left:\s*3px solid/) assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-focus-card\)\s*\{[\s\S]*background:\s*transparent;/) assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-step-index\)\s*\{[\s\S]*background:\s*transparent;[\s\S]*font-size:\s*17px;/) assert.match(aiModeStyles, /\.workbench-ai-date-popover\s*\{[\s\S]*animation:\s*workbenchAiPopoverIn/) assert.match(aiModeStyles, /\.workbench-ai-send-btn:not\(:disabled\)\s*\{[\s\S]*linear-gradient\(135deg,[\s\S]*#1d4ed8/) assert.match(aiModeStyles, /\.workbench-ai-composer--inline\s*\{[\s\S]*min-height:\s*126px;[\s\S]*box-shadow:\s*none;/) assert.match(aiModeStyles, /@media \(prefers-reduced-motion:\s*reduce\)[\s\S]*\.workbench-ai-action,[\s\S]*\.workbench-ai-message,[\s\S]*\.workbench-ai-composer--inline,[\s\S]*\.workbench-ai-date-popover,[\s\S]*\.workbench-ai-thinking-dot\s*\{[\s\S]*animation:\s*none;/) assert.ok(statSync(orbIconAsset).size > 100 * 1024) assert.ok(statSync(orbIconAsset).size < 3 * 1024 * 1024) assert.ok(statSync(orbIconPngAsset).size > 100 * 1024) assert.equal(orbIconBuffer.subarray(0, 6).toString('ascii'), 'GIF89a') assert.ok(countGifFrameBlocks(orbIconBuffer) >= 120) const gifMotion = measureGifMotion(orbIconAsset) assert.ok(gifMotion.seamDelta > gifMotion.medianAdjacentDelta * 0.35) assert.ok(gifMotion.seamDelta < gifMotion.medianAdjacentDelta * 1.8) assert.ok(measureGifDuration(orbIconAsset) >= 8000) assert.ok(measureGifDuration(orbIconAsset) / countGifFrameBlocks(orbIconBuffer) <= 75) const gifPresentation = measureOrbAssetPresentation(orbIconAsset) assert.equal(gifPresentation.width, 192) assert.equal(gifPresentation.height, 192) assert.ok(gifPresentation.minimumCornerLuma > 225) assert.ok(gifPresentation.maximumCornerLuma < 250) assert.ok(gifPresentation.minimumBackgroundSimilarityRatio > 0.25) assert.ok(gifPresentation.minimumForegroundWidthRatio > 0.9) assert.ok(gifPresentation.minimumForegroundHeightRatio > 0.9) const pngPresentation = measureOrbAssetPresentation(orbIconPngAsset) assert.ok(pngPresentation.minimumCornerLuma > 225) assert.ok(pngPresentation.maximumCornerLuma < 250) assert.ok(pngPresentation.minimumBackgroundSimilarityRatio > 0.25) assert.ok(pngPresentation.minimumForegroundWidthRatio > 0.9) assert.ok(pngPresentation.minimumForegroundHeightRatio > 0.9) }) test('AI attachment association notifies shell to refresh the target detail page', () => { const aiModeComponent = readSource('../src/components/business/PersonalWorkbenchAiMode.vue') const workbenchView = readSource('../src/views/PersonalWorkbenchView.vue') const appShellRouteView = readSource('../src/views/AppShellRouteView.vue') const aiModeComposable = readSource('../src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js') const attachmentFlow = readSource('../src/composables/workbenchAiMode/useWorkbenchAiAttachmentAssociationFlow.js') assert.match(aiModeComponent, /defineEmits\(\[[^\]]*'request-updated'/) assert.match(workbenchView, /@request-updated="emit\('request-updated', \$event\)"/) assert.match(workbenchView, /defineEmits\(\[[^\]]*'request-updated'/) assert.match(appShellRouteView, /\s*emit\('request-updated', payload\)/ ) assert.match( attachmentFlow, /notifyRequestUpdated\?\.\(\{[\s\S]*claimId:[\s\S]*runtime\.claimId[\s\S]*uploadedCount:[\s\S]*syncResult\?\.uploadedCount/ ) }) test('AI mode normal assistant requests include OCR context for uploaded receipts', () => { assert.match(aiModeSurface, /function isLikelyAiModeOcrFile\(file = \{\}\)/) assert.match(aiModeSurface, /const aiModeReceiptContextCache = new Map\(\)/) assert.match(aiModeSurface, /const aiModeReceiptRecognitionState = reactive\(\{\}\)/) assert.match(aiModeSurface, /function resolveAiModeReceiptRecognitionState\(file\)/) assert.match(aiModeSurface, /function hasPendingAiModeReceiptRecognition\(files = \[\]\)/) assert.match(aiModeSurface, /function hasFailedAiModeReceiptRecognition\(files = \[\]\)/) assert.match(aiModeSurface, /resolveAiModeReceiptRecognitionState\(selectedFiles\.value\[index\]\)/) assert.match(aiModeSurface, /status:\s*'recognizing'[\s\S]*label:\s*'智能录入识别中'/) assert.match(aiModeSurface, /status:\s*'recognized'[\s\S]*label:\s*detail \? `当前会话已识别/) assert.match(aiModeSurface, /本状态不代表票据夹已有记录/) assert.match(aiModeSurface, /status:\s*'failed'[\s\S]*label:\s*'识别失败'/) assert.match(aiModeSurface, /const isAiModeReceiptRecognitionPending = computed\(\(\) => attachmentFlow\.hasPendingAiModeReceiptRecognition\(selectedFiles\.value\)\)/) assert.match(aiModeSurface, /const hasAiModeReceiptRecognitionFailure = computed\(\(\) => attachmentFlow\.hasFailedAiModeReceiptRecognition\(selectedFiles\.value\)\)/) assert.match(aiModeSurface, /const isAiModeInputLocked = computed\(\(\) => applicationPreviewEstimatePending\.value \|\| isAiModeReceiptRecognitionPending\.value\)/) assert.match(aiModeSurface, /!hasAiModeReceiptRecognitionFailure\.value[\s\S]*Boolean\(assistantDraft\.value\.trim\(\)\)/) assert.match(aiModeSurface, /function resolveAiModeInputLockMessage\(\) \{[\s\S]*附件识别中,请稍等/) assert.match(aiModeSurface, /hasAiModeReceiptRecognitionFailure\.value[\s\S]*请先移除识别失败的附件或重新上传/) assert.match(aiModeSurface, /:placeholder="runtime\.isAiModeInputLocked \? runtime\.aiModeInputLockMessage : placeholder"/) assert.match(aiModeSurface, /function primeAiModeReceiptContext\(files = \[\]\)/) assert.match(aiModeSurface, /function startAiModeReceiptRecognition\(files = \[\], options = \{\}\)/) assert.match(aiModeSurface, /const forceRefresh = Boolean\(options\.forceRefresh\)/) assert.match(aiModeSurface, /if \(!forceRefresh && cached\?\.status === 'resolved'\) \{/) assert.match(aiModeSurface, /startAiModeReceiptRecognition\(files, \{ forceRefresh: true \}\)/) assert.match(aiModeSurface, /function buildAiModeReceiptContextCacheKey\(ocrFiles = \[\]\)/) assert.match(aiModeSurface, /applyAiModeReceiptRecognitionResult\(ocrFiles, context\)/) assert.match(aiModeSurface, /buildFileIdentity\(file\)/) assert.match(aiModeSurface, /watch\(selectedFiles, \(files(?:, previousFiles = \[\])?\) => \{[\s\S]*attachmentFlow\.primeAiModeReceiptContext\(files\)/) assert.match(aiModeSurface, /async function collectAiModeReceiptContext\(files = \[\]\)/) assert.match(aiModeSurface, /cached\?\.status === 'pending'[\s\S]*await cached\.promise/) assert.match(aiModeSurface, /collectReceiptFiles\(\{[\s\S]*files:\s*ocrFiles,[\s\S]*recognizeOcrFiles[\s\S]*\}\)/) assert.match(aiModeSurface, /const receiptContext = await collectAiModeReceiptContext\(files\)/) assert.match(aiModeSurface, /const attachmentOcrDetails = buildInlineAttachmentOcrDetails\(receiptContext, files\)/) assert.match(aiModeSurface, /ocr_summary:\s*receiptContext\.ocrSummary/) assert.match(aiModeSurface, /ocr_documents:\s*receiptContext\.ocrDocuments/) assert.match(aiModeSurface, /attachment_names:\s*receiptContext\.attachmentNames/) assert.match(aiModeSurface, /attachment_count:\s*receiptContext\.attachmentCount/) assert.match(aiModeSurface, /ocr_source_file_names:\s*receiptContext\.ocrSourceFileNames/) assert.match(aiModeSurface, /attachmentOcrDetails/) })