feat(web): 工作台 AI 模式与差旅/风险建议交互优化
- 新增 PersonalWorkbenchAiMode 组件、AI 侧边栏与 orb 机器人视觉资源 - 新增 aiApplicationDraftModel / aiExpenseDraftModel / aiWorkbenchConversationStore 及业务准入 aiSidebarBusinessAccess,支撑 AI 模式下的申请与报销草稿 - 顶栏、侧边栏、工作台样式重构,适配 AI 模式切换与响应式布局 - 同步 steward plan/off_topic、差旅报销引导流、风险建议卡片等测试
This commit is contained in:
51
web/tests/ai-application-draft-model.test.mjs
Normal file
51
web/tests/ai-application-draft-model.test.mjs
Normal file
@@ -0,0 +1,51 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
applyAiApplicationAnswer,
|
||||
buildAiApplicationStepPrompt,
|
||||
buildAiApplicationSummary,
|
||||
createAiApplicationDraft,
|
||||
getAiApplicationCurrentStep,
|
||||
isAiApplicationDraftComplete
|
||||
} from '../src/utils/aiApplicationDraftModel.js'
|
||||
|
||||
test('application draft starts at the reason step', () => {
|
||||
const draft = createAiApplicationDraft('travel', '差旅费')
|
||||
assert.equal(draft.expenseType, 'travel')
|
||||
assert.equal(draft.expenseTypeLabel, '差旅费')
|
||||
assert.equal(draft.stepKey, 'reason')
|
||||
assert.equal(getAiApplicationCurrentStep(draft).key, 'reason')
|
||||
})
|
||||
|
||||
test('answers advance through fields in order and reach summary', () => {
|
||||
let draft = createAiApplicationDraft('travel', '差旅费')
|
||||
draft = applyAiApplicationAnswer(draft, '去上海支持项目部署', [])
|
||||
assert.equal(draft.stepKey, 'time_range')
|
||||
draft = applyAiApplicationAnswer(draft, '2026-06-20 至 2026-06-22,出差 3 天', [])
|
||||
assert.equal(draft.stepKey, 'location')
|
||||
draft = applyAiApplicationAnswer(draft, '上海', [])
|
||||
assert.equal(draft.stepKey, 'amount')
|
||||
draft = applyAiApplicationAnswer(draft, '约 2358 元', [])
|
||||
assert.ok(isAiApplicationDraftComplete(draft))
|
||||
})
|
||||
|
||||
test('step prompt names the type and the current field', () => {
|
||||
const draft = createAiApplicationDraft('travel', '差旅费')
|
||||
const prompt = buildAiApplicationStepPrompt(draft)
|
||||
assert.match(prompt, /差旅费/)
|
||||
assert.match(prompt, /事由/)
|
||||
})
|
||||
|
||||
test('summary lists every filled field', () => {
|
||||
let draft = createAiApplicationDraft('travel', '差旅费')
|
||||
draft = applyAiApplicationAnswer(draft, '去上海支持项目部署', [])
|
||||
draft = applyAiApplicationAnswer(draft, '2026-06-20 至 2026-06-22,出差 3 天', [])
|
||||
draft = applyAiApplicationAnswer(draft, '上海', [])
|
||||
draft = applyAiApplicationAnswer(draft, '约 2358 元', [])
|
||||
const summary = buildAiApplicationSummary(draft)
|
||||
assert.match(summary, /差旅费/)
|
||||
assert.match(summary, /去上海支持项目部署/)
|
||||
assert.match(summary, /2026-06-20 至 2026-06-22,出差 3 天/)
|
||||
assert.match(summary, /约 2358 元/)
|
||||
})
|
||||
73
web/tests/ai-expense-draft-model.test.mjs
Normal file
73
web/tests/ai-expense-draft-model.test.mjs
Normal file
@@ -0,0 +1,73 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
applyAiExpenseAnswer,
|
||||
buildAiExpenseStepPrompt,
|
||||
buildAiExpenseSummary,
|
||||
createAiExpenseDraft,
|
||||
getAiExpenseCurrentStep,
|
||||
isAiExpenseDraftComplete
|
||||
} from '../src/utils/aiExpenseDraftModel.js'
|
||||
|
||||
test('draft starts at the reason step regardless of expense type', () => {
|
||||
const draft = createAiExpenseDraft('transport', '交通费')
|
||||
assert.equal(draft.expenseType, 'transport')
|
||||
assert.equal(draft.expenseTypeLabel, '交通费')
|
||||
assert.equal(draft.stepKey, 'reason')
|
||||
assert.equal(getAiExpenseCurrentStep(draft).key, 'reason')
|
||||
})
|
||||
|
||||
test('answers advance through fields in order and reach the summary step', () => {
|
||||
let draft = createAiExpenseDraft('office', '办公用品费')
|
||||
draft = applyAiExpenseAnswer(draft, '项目现场临时采购', [])
|
||||
assert.equal(draft.stepKey, 'time_range')
|
||||
draft = applyAiExpenseAnswer(draft, '2026-06-15', [])
|
||||
assert.equal(draft.stepKey, 'location')
|
||||
draft = applyAiExpenseAnswer(draft, '京东', [])
|
||||
assert.equal(draft.stepKey, 'amount')
|
||||
draft = applyAiExpenseAnswer(draft, '320元', [])
|
||||
assert.equal(draft.stepKey, 'attachments')
|
||||
draft = applyAiExpenseAnswer(draft, '稍后上传', [])
|
||||
assert.ok(isAiExpenseDraftComplete(draft))
|
||||
})
|
||||
|
||||
test('attachments step collects uploaded file names', () => {
|
||||
let draft = createAiExpenseDraft('office', '办公用品费')
|
||||
draft = applyAiExpenseAnswer(draft, '事由', [])
|
||||
draft = applyAiExpenseAnswer(draft, '2026-06-15', [])
|
||||
draft = applyAiExpenseAnswer(draft, '京东', [])
|
||||
draft = applyAiExpenseAnswer(draft, '320元', [])
|
||||
draft = applyAiExpenseAnswer(draft, '', [{ name: '发票.pdf' }])
|
||||
assert.deepEqual(draft.values.attachment_names, ['发票.pdf'])
|
||||
assert.ok(isAiExpenseDraftComplete(draft))
|
||||
})
|
||||
|
||||
test('step prompt names the type and the current field', () => {
|
||||
const draft = createAiExpenseDraft('transport', '交通费')
|
||||
const prompt = buildAiExpenseStepPrompt(draft)
|
||||
assert.match(prompt, /交通费/)
|
||||
assert.match(prompt, /事由/)
|
||||
})
|
||||
|
||||
test('summary lists every filled field and the linked application', () => {
|
||||
let draft = createAiExpenseDraft('transport', '交通费')
|
||||
draft = {
|
||||
...draft,
|
||||
applicationClaim: {
|
||||
application_claim_no: 'AP-202606-001',
|
||||
application_reason: '送客户去机场',
|
||||
application_business_time: '2026-06-15',
|
||||
application_location: '公司至机场'
|
||||
}
|
||||
}
|
||||
draft = applyAiExpenseAnswer(draft, '送客户去机场', [])
|
||||
draft = applyAiExpenseAnswer(draft, '2026-06-15', [])
|
||||
draft = applyAiExpenseAnswer(draft, '公司至机场', [])
|
||||
draft = applyAiExpenseAnswer(draft, '85元', [])
|
||||
draft = applyAiExpenseAnswer(draft, '稍后上传', [])
|
||||
const summary = buildAiExpenseSummary(draft)
|
||||
assert.match(summary, /交通费/)
|
||||
assert.match(summary, /AP-202606-001/)
|
||||
assert.match(summary, /85元/)
|
||||
})
|
||||
39
web/tests/ai-sidebar-business-access.test.mjs
Normal file
39
web/tests/ai-sidebar-business-access.test.mjs
Normal file
@@ -0,0 +1,39 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { resolveAiSidebarBusinessViewIds } from '../src/utils/aiSidebarBusinessAccess.js'
|
||||
|
||||
test('AI sidebar shows three business entries for regular employees', () => {
|
||||
assert.deepEqual(resolveAiSidebarBusinessViewIds({ name: '普通员工', roleCodes: [] }), [
|
||||
'documents',
|
||||
'receiptFolder',
|
||||
'policies'
|
||||
])
|
||||
})
|
||||
|
||||
test('AI sidebar adds budget management for budget monitors', () => {
|
||||
assert.deepEqual(resolveAiSidebarBusinessViewIds({ name: '预算管理员', roleCodes: ['budget_monitor'] }), [
|
||||
'documents',
|
||||
'receiptFolder',
|
||||
'policies',
|
||||
'budget'
|
||||
])
|
||||
})
|
||||
|
||||
test('AI sidebar adds finance capabilities for finance users', () => {
|
||||
assert.deepEqual(resolveAiSidebarBusinessViewIds({ name: '财务负责人', roleCodes: ['finance'] }), [
|
||||
'documents',
|
||||
'receiptFolder',
|
||||
'policies',
|
||||
'overview',
|
||||
'audit',
|
||||
'digitalEmployees'
|
||||
])
|
||||
})
|
||||
|
||||
test('AI sidebar keeps workbench and settings out of the steward business layer', () => {
|
||||
const viewIds = resolveAiSidebarBusinessViewIds({ username: 'admin', isAdmin: true, roleCodes: ['admin'] })
|
||||
|
||||
assert.equal(viewIds.includes('workbench'), false)
|
||||
assert.equal(viewIds.includes('settings'), false)
|
||||
})
|
||||
193
web/tests/ai-sidebar-rail-mode.test.mjs
Normal file
193
web/tests/ai-sidebar-rail-mode.test.mjs
Normal file
@@ -0,0 +1,193 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const appShell = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/AppShellRouteView.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const aiSidebar = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/layout/AiSidebarRail.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const aiSidebarStyles = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/components/ai-sidebar-rail.css', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const aiBusinessAccess = readFileSync(
|
||||
fileURLToPath(new URL('../src/utils/aiSidebarBusinessAccess.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const appStyles = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/app.css', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const extractCssBlock = (source, selector) => source.match(new RegExp(`${selector}\\s*\\{([\\s\\S]*?)\\n\\}`))?.[1] || ''
|
||||
|
||||
test('workbench AI mode swaps the traditional rail for the AI three-layer rail', () => {
|
||||
assert.match(appShell, /import AiSidebarRail from '\.\.\/components\/layout\/AiSidebarRail\.vue'/)
|
||||
assert.match(appShell, /<Transition[\s\S]*name="sidebar-mode-fade"[\s\S]*mode="out-in"/)
|
||||
assert.match(appShell, /<AiSidebarRail[\s\S]*v-if="isAiShellMode"[\s\S]*key="ai-sidebar"/)
|
||||
assert.match(appShell, /const isAiShellMode = computed\(\(\) => workbenchMode\.value === 'ai'\)/)
|
||||
assert.match(appShell, /const isWorkbenchAiMode = computed\(\(\) => activeView\.value === 'workbench' && workbenchMode\.value === 'ai'\)/)
|
||||
assert.match(appShell, /@new-chat="openAiSidebarNewChat"/)
|
||||
assert.match(appShell, /@open-recent="openAiSidebarRecent"/)
|
||||
assert.match(appShell, /@rename-conversation="handleAiConversationRename"/)
|
||||
assert.match(appShell, /@logout="handleLogout"/)
|
||||
assert.match(appShell, /import \{ loadAiWorkbenchConversationHistory, saveAiWorkbenchConversation \} from '\.\.\/utils\/aiWorkbenchConversationStore\.js'/)
|
||||
assert.match(appShell, /function openAiSidebarNewChat\(\)/)
|
||||
assert.match(appShell, /function openAiSidebarRecent\(item = \{\}\)/)
|
||||
assert.match(appShell, /function handleAiConversationRename\(payload = \{\}\)/)
|
||||
assert.match(appShell, /import \{ computed, nextTick, onBeforeUnmount, onMounted, ref, watch \} from 'vue'/)
|
||||
assert.match(appShell, /async function openAiConversationWorkspace\(type, payload = null\)/)
|
||||
assert.match(appShell, /const navigation = handleNavigate\('workbench'\)/)
|
||||
assert.match(appShell, /if \(navigation && typeof navigation\.then === 'function'\)[\s\S]*await navigation/)
|
||||
assert.match(appShell, /await nextTick\(\)/)
|
||||
assert.match(appShell, /dispatchAiSidebarCommand\(type, payload\)/)
|
||||
assert.match(appShell, /void openAiConversationWorkspace\('new-chat'\)/)
|
||||
assert.match(appShell, /void openAiConversationWorkspace\('open-recent', item\)/)
|
||||
assert.doesNotMatch(appShell, /openAiSidebarSearchChat/)
|
||||
assert.match(appShell, /const aiSidebarCommand = ref\(\{ seq: 0, type: '', payload: null \}\)/)
|
||||
assert.match(appShell, /const aiConversationHistory = ref\(\[\]\)/)
|
||||
assert.match(appShell, /:active-conversation-id="aiActiveConversationId"/)
|
||||
assert.match(appShell, /:conversation-history="aiConversationHistory"/)
|
||||
assert.match(appShell, /:brand-name="PRODUCT_DISPLAY_NAME"/)
|
||||
assert.match(appShell, /:brand-logo="companyProfile\.logo"/)
|
||||
assert.match(appShell, /:company-name="ENTERPRISE_DISPLAY_NAME"/)
|
||||
assert.match(appShell, /:ai-sidebar-command="aiSidebarCommand"/)
|
||||
assert.match(appShell, /@ai-conversation-change="handleAiConversationChange"/)
|
||||
assert.match(appShell, /@ai-conversation-history-change="handleAiConversationHistoryChange"/)
|
||||
assert.match(appShell, /function dispatchAiSidebarCommand\(type, payload = null\)/)
|
||||
assert.match(appShell, /function handleAiConversationHistoryChange\(payload = \[\]\)/)
|
||||
assert.match(appShell, /loadAiWorkbenchConversationHistory\(user \|\| \{\}\)/)
|
||||
assert.match(appShell, /saveAiWorkbenchConversation\(currentUser\.value \|\| \{\},[\s\S]*\.\.\.target,[\s\S]*title/)
|
||||
assert.match(appShell, /:current-user="currentUser"/)
|
||||
assert.doesNotMatch(appShell, /restoreLatestConversation:\s*true/)
|
||||
assert.match(appShell, /'workbench-ai-sidebar-active': isAiShellMode/)
|
||||
assert.match(appShell, /sidebarCollapsedBeforeAiMode\.value = sidebarCollapsed\.value/)
|
||||
assert.match(appShell, /sidebarCollapsed\.value = false/)
|
||||
assert.match(appShell, /sidebarCollapsed\.value = sidebarCollapsedBeforeAiMode\.value/)
|
||||
assert.match(appStyles, /\.app\s*\{[\s\S]*--sidebar-expanded-width:\s*304px;/)
|
||||
assert.match(appStyles, /\.sidebar-mode-fade-enter-active,[\s\S]*\.sidebar-mode-fade-leave-active\s*\{[\s\S]*opacity 180ms/)
|
||||
})
|
||||
|
||||
test('AI sidebar has quick actions, business navigation and conversation history layers', () => {
|
||||
assert.match(aiSidebar, /aria-label="AI模式导航"/)
|
||||
assert.match(aiSidebar, /class="ai-rail-brand"/)
|
||||
assert.match(aiSidebar, /aria-label="当前产品标识"/)
|
||||
assert.match(aiSidebar, /displayBrandName/)
|
||||
assert.match(aiSidebar, /brandName:\s*\{\s*type:\s*String/)
|
||||
assert.match(aiSidebar, /brandLogo:\s*\{\s*type:\s*String/)
|
||||
assert.match(aiSidebar, /String\(props\.brandName \|\| '易财费控'\)/)
|
||||
assert.doesNotMatch(aiSidebar, /远光软件股份有限公司/)
|
||||
assert.doesNotMatch(aiSidebar, /AI Workbench/)
|
||||
assert.match(aiSidebar, /aria-label="对话操作"/)
|
||||
assert.match(aiSidebar, /新建对话/)
|
||||
assert.match(aiSidebar, /查询对话/)
|
||||
assert.doesNotMatch(aiSidebar, /自定义/)
|
||||
assert.doesNotMatch(aiSidebar, /业务工作舱/)
|
||||
assert.match(aiSidebar, /resolveAiSidebarBusinessViewIds/)
|
||||
assert.match(aiSidebar, /\.filter\(\(item\) => aiBusinessViewIds\.value\.has\(item\.id\)\)/)
|
||||
assert.match(aiSidebar, /class="ai-nav-list"/)
|
||||
assert.match(aiSidebar, /v-for="item in businessNavItems"/)
|
||||
assert.match(aiSidebar, /ai-nav-copy/)
|
||||
assert.match(aiSidebar, /item\.aiIcon/)
|
||||
assert.match(aiSidebar, /aria-current/)
|
||||
assert.doesNotMatch(aiSidebar, /displayHint/)
|
||||
assert.doesNotMatch(aiSidebar, /个人工作台/)
|
||||
assert.doesNotMatch(aiSidebar, /待办与助手/)
|
||||
assert.doesNotMatch(aiSidebar, /v-html="item\.icon"/)
|
||||
assert.match(aiSidebar, /最近对话/)
|
||||
assert.match(aiSidebar, /conversationHistory:\s*\{ type:\s*Array,\s*default:\s*\(\) => \[\] \}/)
|
||||
assert.match(aiSidebar, /const conversationSearchOpen = ref\(false\)/)
|
||||
assert.match(aiSidebar, /const conversationSearchQuery = ref\(''\)/)
|
||||
assert.match(aiSidebar, /function openConversationSearch\(\)/)
|
||||
assert.match(aiSidebar, /<template[\s\S]*v-for="action in quickActions"[\s\S]*:key="action\.event"/)
|
||||
assert.match(aiSidebar, /v-if="action\.event === 'search' && conversationSearchOpen"[\s\S]*class="ai-conversation-search"/)
|
||||
assert.match(aiSidebar, /v-else[\s\S]*class="ai-quick-btn"/)
|
||||
assert.match(aiSidebar, /placeholder="搜索对话标题"/)
|
||||
assert.match(aiSidebar, /v-model="conversationSearchQuery"/)
|
||||
assert.doesNotMatch(aiSidebar, /<\/button>\s*<label v-if="conversationSearchOpen" class="ai-conversation-search"/)
|
||||
assert.match(aiSidebar, /filteredConversationHistory/)
|
||||
assert.match(aiSidebar, /String\(recent\.title \|\| ''\)\.toLowerCase\(\)\.includes\(query\)/)
|
||||
assert.match(aiSidebar, /normalizedConversationHistory/)
|
||||
assert.match(aiSidebar, /v-for="recent in filteredConversationHistory"/)
|
||||
assert.match(aiSidebar, /activeConversationId === recent\.id/)
|
||||
assert.match(aiSidebar, /ai-recent-main/)
|
||||
assert.match(aiSidebar, /emit\('open-recent', recent\)/)
|
||||
assert.match(aiSidebar, /@dblclick\.stop="startEditingRecentTitle\(recent\)"/)
|
||||
assert.match(aiSidebar, /v-if="editingConversationId === recent\.id"/)
|
||||
assert.match(aiSidebar, /v-model="editingConversationTitle"/)
|
||||
assert.match(aiSidebar, /@keydown\.enter\.prevent="commitRecentTitleEdit\(recent\)"/)
|
||||
assert.match(aiSidebar, /emit\('rename-conversation'/)
|
||||
assert.match(aiSidebar, /暂无历史对话/)
|
||||
assert.match(aiSidebar, /没有匹配的对话/)
|
||||
assert.doesNotMatch(aiSidebar, /差旅报销口径核对/)
|
||||
assert.doesNotMatch(aiSidebar, /预算占用预警分析/)
|
||||
assert.doesNotMatch(aiSidebar, /票据识别结果复核/)
|
||||
assert.match(aiSidebar, /aria-label="当前用户"/)
|
||||
assert.match(aiSidebar, /displayUser\.avatar/)
|
||||
assert.match(aiSidebar, /displayUser\.subtitle/)
|
||||
assert.match(aiSidebar, /aria-label="用户操作"/)
|
||||
assert.match(aiSidebar, /emit\('logout'\)/)
|
||||
assert.match(aiSidebar, /defineEmits\(\['navigate', 'new-chat', 'open-recent', 'rename-conversation', 'logout'\]\)/)
|
||||
assert.doesNotMatch(aiSidebar, /search-chat/)
|
||||
assert.doesNotMatch(aiSidebar, /打开系统设置/)
|
||||
assert.doesNotMatch(aiSidebar, /mdi-chevron-up/)
|
||||
assert.doesNotMatch(aiSidebar, /mockRecents/)
|
||||
})
|
||||
|
||||
test('AI sidebar visual treatment keeps a restrained three-layer workspace', () => {
|
||||
const quickButtonBlock = extractCssBlock(aiSidebarStyles, '\\.ai-quick-btn')
|
||||
const quickPrimaryBlock = extractCssBlock(aiSidebarStyles, '\\.ai-quick-btn\\.primary')
|
||||
const navListBlock = extractCssBlock(aiSidebarStyles, '\\.ai-nav-list')
|
||||
|
||||
assert.match(aiSidebarStyles, /--ai-rail-bg:\s*#f7f9fc;/)
|
||||
assert.match(aiSidebarStyles, /\.ai-rail\s*\{[\s\S]*grid-template-rows:\s*auto auto auto auto auto minmax\(0,\s*1fr\) auto;/)
|
||||
assert.match(aiSidebarStyles, /\.ai-rail::before\s*\{[\s\S]*repeating-linear-gradient/)
|
||||
assert.match(aiSidebarStyles, /\.ai-rail-brand\s*\{[\s\S]*min-height:\s*74px;[\s\S]*grid-template-columns:\s*42px minmax\(0,\s*1fr\);/)
|
||||
assert.match(aiSidebarStyles, /\.ai-brand-logo\s*\{[\s\S]*width:\s*42px;[\s\S]*height:\s*42px;/)
|
||||
assert.match(aiSidebarStyles, /\.ai-brand-logo svg\s*\{[\s\S]*width:\s*26px;[\s\S]*height:\s*26px;/)
|
||||
assert.match(aiSidebar, /icon:\s*'mdi mdi-plus'/)
|
||||
assert.match(aiSidebarStyles, /\.ai-rail-quick\s*\{[\s\S]*gap:\s*6px;[\s\S]*padding:\s*8px 18px 12px;/)
|
||||
assert.match(quickButtonBlock, /min-height:\s*48px;/)
|
||||
assert.match(quickButtonBlock, /grid-template-columns:\s*28px minmax\(0,\s*1fr\);/)
|
||||
assert.match(quickButtonBlock, /background:\s*transparent;/)
|
||||
assert.match(quickButtonBlock, /border-color:\s*transparent;/)
|
||||
assert.match(quickButtonBlock, /box-shadow:\s*none;/)
|
||||
assert.match(quickPrimaryBlock, /background:\s*transparent;/)
|
||||
assert.match(quickPrimaryBlock, /box-shadow:\s*none;/)
|
||||
assert.doesNotMatch(quickButtonBlock, /rgba\(255,\s*255,\s*255/)
|
||||
assert.match(navListBlock, /gap:\s*6px;/)
|
||||
assert.doesNotMatch(navListBlock, /grid-template-columns:\s*repeat\(2/)
|
||||
assert.doesNotMatch(navListBlock, /border-radius:/)
|
||||
assert.doesNotMatch(navListBlock, /box-shadow:/)
|
||||
assert.match(aiSidebarStyles, /\.ai-nav-btn\s*\{[\s\S]*min-height:\s*48px;/)
|
||||
assert.match(aiSidebarStyles, /\.ai-nav-btn\s*\{[\s\S]*grid-template-columns:\s*32px minmax\(0,\s*1fr\);/)
|
||||
assert.match(aiSidebarStyles, /\.ai-nav-btn\.active\s*\{[\s\S]*background:[\s\S]*linear-gradient\(90deg,\s*rgba\(45,\s*114,\s*217,\s*0\.095\)/)
|
||||
assert.match(aiSidebarStyles, /\.ai-rail\.rail-collapsed \.ai-nav-list\s*\{[\s\S]*grid-template-columns:\s*1fr;/)
|
||||
assert.match(aiSidebarStyles, /\.ai-rail-recents\s*\{[\s\S]*grid-template-rows:\s*auto minmax\(0,\s*1fr\);/)
|
||||
assert.match(aiSidebarStyles, /\.ai-recents-list\s*\{[\s\S]*overflow-y:\s*auto;[\s\S]*scrollbar-width:\s*none;/)
|
||||
assert.match(aiSidebarStyles, /\.ai-recents-list::-webkit-scrollbar\s*\{[\s\S]*display:\s*none;/)
|
||||
assert.match(aiSidebarStyles, /\.ai-recents-empty\s*\{[\s\S]*border:\s*1px dashed/)
|
||||
assert.match(aiSidebarStyles, /\.ai-recent-item\s*\{[\s\S]*min-height:\s*56px;/)
|
||||
assert.match(aiSidebarStyles, /\.ai-rail-user\s*\{[\s\S]*height:\s*72px;/)
|
||||
assert.match(aiSidebarStyles, /\.ai-rail-user\s*\{[\s\S]*border-radius:\s*0;/)
|
||||
assert.match(aiSidebarStyles, /\.ai-user-actions\s*\{[\s\S]*grid-template-columns:\s*44px;/)
|
||||
assert.match(aiSidebarStyles, /\.ai-rail\.rail-collapsed \.ai-rail-recents,/)
|
||||
assert.match(aiSidebarStyles, /\.ai-rail\.rail-collapsed \.ai-user-copy,/)
|
||||
assert.match(aiSidebarStyles, /\.ai-rail\.rail-collapsed \.ai-user-actions\s*\{[\s\S]*display:\s*none;/)
|
||||
})
|
||||
|
||||
test('AI sidebar business layer is role scoped for Xiaocai steward mode', () => {
|
||||
assert.match(aiBusinessAccess, /AI_SIDEBAR_BASE_BUSINESS_VIEW_IDS = \['documents', 'receiptFolder', 'policies'\]/)
|
||||
assert.match(aiBusinessAccess, /ROLE_VIEW_ADDITIONS = \{[\s\S]*budget:\s*\['budget'\]/)
|
||||
assert.match(aiBusinessAccess, /roleCodeSet\.has\('budget_monitor'\)[\s\S]*ROLE_VIEW_ADDITIONS\.budget/)
|
||||
assert.doesNotMatch(aiBusinessAccess, /'workbench'/)
|
||||
})
|
||||
69
web/tests/ai-workbench-conversation-store.test.mjs
Normal file
69
web/tests/ai-workbench-conversation-store.test.mjs
Normal file
@@ -0,0 +1,69 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
deleteAiWorkbenchConversation,
|
||||
loadAiWorkbenchConversationHistory,
|
||||
saveAiWorkbenchConversation
|
||||
} from '../src/utils/aiWorkbenchConversationStore.js'
|
||||
|
||||
function installLocalStorageMock() {
|
||||
const store = new Map()
|
||||
globalThis.window = {
|
||||
localStorage: {
|
||||
getItem(key) {
|
||||
return store.has(key) ? store.get(key) : null
|
||||
},
|
||||
setItem(key, value) {
|
||||
store.set(key, String(value))
|
||||
},
|
||||
removeItem(key) {
|
||||
store.delete(key)
|
||||
},
|
||||
clear() {
|
||||
store.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
test('AI workbench conversation store persists scoped history for sidebar sessions', () => {
|
||||
installLocalStorageMock()
|
||||
const user = { username: 'caoxiaozhu', email: 'caoxiaozhu@xf.com', name: '曹笑竹' }
|
||||
const anotherUser = { username: 'budget-user' }
|
||||
|
||||
saveAiWorkbenchConversation(user, {
|
||||
id: 'conv-first',
|
||||
title: '',
|
||||
updatedAt: Date.now() - 3000,
|
||||
messages: [
|
||||
{ id: 'u1', role: 'user', content: '帮我核对差旅报销口径' },
|
||||
{ id: 'a1', role: 'assistant', content: '我会根据制度和票据要求继续核对。' }
|
||||
]
|
||||
})
|
||||
saveAiWorkbenchConversation(user, {
|
||||
id: 'conv-second',
|
||||
title: '预算占用分析',
|
||||
updatedAt: Date.now(),
|
||||
stewardState: { intent: 'budget_check' },
|
||||
messages: [
|
||||
{ id: 'u2', role: 'user', content: '分析本月预算占用' },
|
||||
{ id: 'a2', role: 'assistant', content: '本月预算占用需要结合部门额度和已提交单据。' }
|
||||
]
|
||||
})
|
||||
|
||||
const history = loadAiWorkbenchConversationHistory(user)
|
||||
assert.equal(history.length, 2)
|
||||
assert.equal(history[0].id, 'conv-second')
|
||||
assert.equal(history[0].title, '预算占用分析')
|
||||
assert.equal(history[0].stewardState.intent, 'budget_check')
|
||||
assert.equal(history[1].title, '帮我核对差旅报销口径')
|
||||
assert.equal(history[1].prompt, '帮我核对差旅报销口径')
|
||||
assert.ok(history[0].time)
|
||||
assert.deepEqual(loadAiWorkbenchConversationHistory(anotherUser), [])
|
||||
|
||||
const nextHistory = deleteAiWorkbenchConversation(user, 'conv-second')
|
||||
assert.equal(nextHistory.length, 1)
|
||||
assert.equal(nextHistory[0].id, 'conv-first')
|
||||
})
|
||||
@@ -102,8 +102,8 @@ test('workbench summary merges approval inbox requests without polluting documen
|
||||
|
||||
test('workbench progress refreshes after homepage create or detail updates', () => {
|
||||
assert.match(appShellComposable, /async function handleDraftSaved\(payload = \{\}\) \{[\s\S]*await reloadWorkbenchRequests\(\)/)
|
||||
assert.match(appShellComposable, /async function handleRequestUpdated\(\) \{[\s\S]*await reloadWorkbenchRequests\(\)[\s\S]*await refreshSelectedRequestDetail\(String\(route\.params\.requestId \|\| ''\)\)/)
|
||||
assert.doesNotMatch(appShellComposable, /async function handleRequestUpdated\(\) \{[\s\S]*await reloadRequests\(\)[\s\S]*await refreshSelectedRequestDetail/)
|
||||
assert.match(appShellComposable, /async function handleRequestUpdated\(payload = \{\}\) \{[\s\S]*await reloadWorkbenchRequests\(\)[\s\S]*await refreshSelectedRequestDetail\(claimId\)/)
|
||||
assert.doesNotMatch(appShellComposable, /async function handleRequestUpdated\(payload = \{\}\) \{[\s\S]*await reloadRequests\(\)[\s\S]*await refreshSelectedRequestDetail/)
|
||||
})
|
||||
|
||||
test('workbench progress refresh is silent to avoid homepage flashing', () => {
|
||||
@@ -136,7 +136,7 @@ test('document detail refreshes claim detail instead of relying on stale list ca
|
||||
assert.match(appShellComposable, /const snapshot = normalizeRequestForUi\(selectedRequestSnapshot\.value\)[\s\S]*if \(isSameRequestIdentity\(snapshot, requestId\)\) \{[\s\S]*return snapshot/)
|
||||
assert.match(appShellComposable, /async function refreshSelectedRequestDetail\(requestOrId = selectedRequestSnapshot\.value\) \{[\s\S]*fetchExpenseClaimDetail\(lookupId\)[\s\S]*mapExpenseClaimToRequest\(payload\)[\s\S]*upsertRequestSnapshot\(mappedRequest\)/)
|
||||
assert.match(appShellComposable, /function openRequestDetail\(request, options = \{\}\) \{[\s\S]*void refreshSelectedRequestDetail\(request\)/)
|
||||
assert.match(appShellComposable, /async function handleRequestUpdated\(\) \{[\s\S]*await reloadWorkbenchRequests\(\)[\s\S]*await refreshSelectedRequestDetail\(String\(route\.params\.requestId \|\| ''\)\)/)
|
||||
assert.match(appShellComposable, /async function handleRequestUpdated\(payload = \{\}\) \{[\s\S]*await reloadWorkbenchRequests\(\)[\s\S]*await refreshSelectedRequestDetail\(claimId\)/)
|
||||
assert.match(appShellComposable, /route\.name === 'app-document-detail'[\s\S]*void refreshSelectedRequestDetail\(String\(route\.params\.requestId \|\| ''\)\)/)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
ASSISTANT_SCOPE_ACTION_FILL_COMPOSER
|
||||
} from '../src/utils/assistantSessionScope.js'
|
||||
import {
|
||||
mergeComposerPrefill,
|
||||
resolveSuggestedActionPrefill
|
||||
@@ -41,3 +44,23 @@ test('composer prefill appends to existing draft without duplication', () => {
|
||||
assert.equal(mergeComposerPrefill('地点:上海', '事由:'), '地点:上海\n事由:')
|
||||
assert.equal(mergeComposerPrefill('地点:上海\n事由:', '事由:'), '地点:上海\n事由:')
|
||||
})
|
||||
|
||||
test('fill_composer action resolves payload.fill_text as prefill', () => {
|
||||
assert.equal(
|
||||
resolveSuggestedActionPrefill({
|
||||
action_type: ASSISTANT_SCOPE_ACTION_FILL_COMPOSER,
|
||||
payload: { fill_text: '我想要申请明天去北京出差3天' }
|
||||
}),
|
||||
'我想要申请明天去北京出差3天'
|
||||
)
|
||||
})
|
||||
|
||||
test('fill_composer action without fill_text falls back to empty (no application_field lookup)', () => {
|
||||
assert.equal(
|
||||
resolveSuggestedActionPrefill({
|
||||
action_type: ASSISTANT_SCOPE_ACTION_FILL_COMPOSER,
|
||||
payload: {}
|
||||
}),
|
||||
''
|
||||
)
|
||||
})
|
||||
|
||||
@@ -5,12 +5,15 @@ import { fileURLToPath } from 'node:url'
|
||||
|
||||
import {
|
||||
ATTACHMENT_ASSOCIATION_CONFIRM_HREF,
|
||||
attachReceiptFolderIdsToFiles,
|
||||
buildAttachmentAssociationConfirmationMessage,
|
||||
buildOcrFilePreviews,
|
||||
buildReviewFilePreviewsFromReviewPayload,
|
||||
buildUnsavedDraftAttachmentConfirmationMessage,
|
||||
collectReceiptFiles,
|
||||
filterPersistableFilePreviews,
|
||||
mergeFilePreviews
|
||||
mergeFilePreviews,
|
||||
normalizeOcrDocuments
|
||||
} from '../src/views/scripts/travelReimbursementAttachmentModel.js'
|
||||
import {
|
||||
buildDraftAssociationQueryPayload,
|
||||
@@ -171,6 +174,90 @@ test('OCR preview builders keep hotel receipt image previews when preview kind i
|
||||
assert.deepEqual(reviewPreviews, [{ filename: 'hotel.png', kind: 'image', url: dataUrl }])
|
||||
})
|
||||
|
||||
test('OCR receipt folder ids are kept for final draft attachment association', () => {
|
||||
const files = [
|
||||
{ name: 'invoice.png' },
|
||||
{ name: 'taxi.pdf' }
|
||||
]
|
||||
const ocrPayload = {
|
||||
documents: [
|
||||
{
|
||||
filename: 'invoice.png',
|
||||
receipt_id: 'receipt-1',
|
||||
receipt_status: 'unlinked',
|
||||
receipt_preview_url: '/receipt-folder/receipt-1/preview',
|
||||
receipt_source_url: '/receipt-folder/receipt-1/source'
|
||||
},
|
||||
{
|
||||
filename: 'taxi.pdf',
|
||||
receipt_id: 'receipt-2',
|
||||
receipt_status: 'unlinked',
|
||||
receipt_preview_url: '/receipt-folder/receipt-2/preview',
|
||||
receipt_source_url: '/receipt-folder/receipt-2/source'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const documents = normalizeOcrDocuments(ocrPayload)
|
||||
|
||||
assert.equal(documents[0].receipt_id, 'receipt-1')
|
||||
assert.equal(documents[0].receipt_status, 'unlinked')
|
||||
assert.equal(documents[0].receipt_preview_url, '/receipt-folder/receipt-1/preview')
|
||||
assert.equal(documents[0].receipt_source_url, '/receipt-folder/receipt-1/source')
|
||||
assert.equal(attachReceiptFolderIdsToFiles(files, ocrPayload), 2)
|
||||
assert.equal(files[0].receiptId, 'receipt-1')
|
||||
assert.equal(files[1].receiptId, 'receipt-2')
|
||||
assert.equal(Object.getOwnPropertyDescriptor(files[0], 'receiptId')?.enumerable, false)
|
||||
})
|
||||
|
||||
test('receipt files are collected through a single OCR persistence entry before draft association', async () => {
|
||||
const files = [
|
||||
{ name: 'invoice.png' }
|
||||
]
|
||||
let recognizeCallCount = 0
|
||||
|
||||
const collected = await collectReceiptFiles({
|
||||
files,
|
||||
recognizeOcrFiles: async (inputFiles, options) => {
|
||||
recognizeCallCount += 1
|
||||
assert.equal(inputFiles, files)
|
||||
assert.equal(options.timeoutMs, 90000)
|
||||
return {
|
||||
documents: [
|
||||
{
|
||||
filename: 'invoice.png',
|
||||
summary: '发票金额 100 元',
|
||||
preview_kind: 'image',
|
||||
preview_data_url: 'data:image/png;base64,abc123',
|
||||
receipt_id: 'receipt-collect-1',
|
||||
receipt_status: 'unlinked',
|
||||
receipt_preview_url: '/receipt-folder/receipt-collect-1/preview',
|
||||
receipt_source_url: '/receipt-folder/receipt-collect-1/source'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
assert.equal(recognizeCallCount, 1)
|
||||
assert.equal(files[0].receiptId, 'receipt-collect-1')
|
||||
assert.equal(collected.ocrPayload.documents[0].receipt_id, 'receipt-collect-1')
|
||||
assert.equal(collected.ocrDocuments[0].receipt_id, 'receipt-collect-1')
|
||||
assert.equal(collected.ocrSummary, 'invoice.png:发票金额 100 元')
|
||||
assert.deepEqual(collected.ocrFilePreviews, [
|
||||
{ filename: 'invoice.png', kind: 'image', url: 'data:image/png;base64,abc123' }
|
||||
])
|
||||
|
||||
const submitComposerSource = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
assert.match(submitComposerSource, /collectReceiptFiles\(/)
|
||||
assert.doesNotMatch(submitComposerSource, /recognizeOcrFiles\(files,[\s\S]*attachReceiptFolderIdsToFiles/)
|
||||
assert.doesNotMatch(submitComposerSource, /ocrDocuments = normalizeOcrDocuments\(ocrPayload\)/)
|
||||
assert.doesNotMatch(submitComposerSource, /ocrFilePreviews = buildOcrFilePreviews\(ocrPayload\)/)
|
||||
})
|
||||
|
||||
test('file preview cache replaces temporary object urls and never persists them', () => {
|
||||
const merged = mergeFilePreviews(
|
||||
[
|
||||
|
||||
@@ -53,9 +53,11 @@ import {
|
||||
} from '../src/views/scripts/budgetAssistantReportModel.js'
|
||||
import { resolveStewardTypewriterNextIndex } from '../src/views/scripts/stewardTypewriter.js'
|
||||
import {
|
||||
ASSISTANT_SCOPE_ACTION_SWITCH,
|
||||
ASSISTANT_SCOPE_SESSION_APPLICATION,
|
||||
ASSISTANT_SCOPE_SESSION_EXPENSE,
|
||||
ASSISTANT_SCOPE_SESSION_STEWARD,
|
||||
buildUnsupportedBusinessScopeConversation,
|
||||
inferAssistantScopeTarget,
|
||||
resolveAssistantScopeGuard
|
||||
} from '../src/utils/assistantSessionScope.js'
|
||||
@@ -231,16 +233,42 @@ test('application intent uses local preview instead of immediate orchestrator ca
|
||||
assert.match(buildLocalApplicationPreviewMessage(preview), /点击对应行即可直接编辑/)
|
||||
})
|
||||
|
||||
test('unsupported business guidance opens in assistant conversation form', () => {
|
||||
const conversation = buildUnsupportedBusinessScopeConversation('你好')
|
||||
|
||||
assert.equal(conversation.state_json.session_type, ASSISTANT_SCOPE_SESSION_STEWARD)
|
||||
assert.equal(conversation.messages.length, 1)
|
||||
assert.equal(conversation.messages[0].role, 'assistant')
|
||||
assert.match(conversation.messages[0].content, /小财管家暂时不处理「你好」/)
|
||||
assert.equal(conversation.messages[0].assistantName, '小财管家')
|
||||
assert.match(conversation.messages[0].content, /### 当前可继续的场景/)
|
||||
assert.equal(
|
||||
conversation.messages[0].message_json.orchestrator_payload.result.suggested_actions.length,
|
||||
4
|
||||
)
|
||||
})
|
||||
|
||||
test('assistant scope guard blocks unsupported non-financial intent', () => {
|
||||
const greetingGuard = resolveAssistantScopeGuard('你好', ASSISTANT_SCOPE_SESSION_APPLICATION)
|
||||
const guard = resolveAssistantScopeGuard('帮我写一首诗,主题是春天', ASSISTANT_SCOPE_SESSION_APPLICATION)
|
||||
|
||||
assert.equal(greetingGuard.blocked, true)
|
||||
assert.equal(greetingGuard.targetSessionType, '')
|
||||
assert.equal(greetingGuard.suggestedActions.length, 4)
|
||||
assert.deepEqual(
|
||||
greetingGuard.suggestedActions.map((item) => item.action_type),
|
||||
Array.from({ length: 4 }, () => ASSISTANT_SCOPE_ACTION_SWITCH)
|
||||
)
|
||||
assert.match(greetingGuard.text, /小财管家暂时不处理「你好」/)
|
||||
assert.match(greetingGuard.text, /你可以直接点下面的场景继续/)
|
||||
assert.equal(guard.suggestedActions.length, 4)
|
||||
assert.equal(guard.blocked, true)
|
||||
assert.equal(guard.targetSessionType, '')
|
||||
assert.match(guard.text, /此意图系统不支持/)
|
||||
assert.match(guard.text, /当前系统支持的业务范围/)
|
||||
assert.deepEqual(guard.suggestedActions, [])
|
||||
})
|
||||
|
||||
|
||||
test('assistant scope guard routes related business intent instead of blocking', () => {
|
||||
const guard = resolveAssistantScopeGuard('帮我查一下报销单状态', ASSISTANT_SCOPE_SESSION_APPLICATION)
|
||||
|
||||
|
||||
@@ -88,6 +88,20 @@ test('daily amount trend uses stacked category bars with clear unit and legend',
|
||||
assert.match(trendChart, /legendItems/)
|
||||
assert.match(trendChart, /单位:元/)
|
||||
assert.match(trendChart, /单位:单/)
|
||||
assert.match(trendChart, /comparisonAmount/)
|
||||
assert.match(trendChart, /isComparisonMode/)
|
||||
assert.match(trendChart, /props\.mode === 'compareAmount'/)
|
||||
assert.match(trendChart, /props\.comparisonLabel/)
|
||||
assert.match(trendChart, /const compactScale = computed/)
|
||||
assert.match(trendChart, /const chartGrid = computed/)
|
||||
assert.match(trendChart, /gridBottom:\s*props\.compact \? 18 : 22/)
|
||||
assert.match(trendChart, /gridTop:\s*props\.compact \? 10 : 12/)
|
||||
assert.match(trendChart, /splitNumber:\s*props\.compact \? 2 : 5/)
|
||||
assert.match(trendChart, /primaryLineWidth:\s*props\.compact \? 3\.8 : 3/)
|
||||
assert.match(trendChart, /axisLabelSize:\s*props\.compact \? 12 : 11/)
|
||||
assert.match(trendChart, /lineStyle:[\s\S]*type:\s*'dashed'/)
|
||||
assert.match(trendChart, /\.trend-chart-compact\s*\{[\s\S]*min-height:\s*124px;/)
|
||||
assert.match(trendChart, /\.trend-chart-compact \.chart-legend\s*\{[\s\S]*font-size:\s*13px;/)
|
||||
assert.doesNotMatch(trendChart, /name:\s*isCountMode\.value/)
|
||||
assert.doesNotMatch(trendChart, /stack: 'expenseAmount'/)
|
||||
})
|
||||
|
||||
@@ -38,9 +38,6 @@ const workbenchInsightStyles = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/components/personal-workbench-insights.css', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const heroBackgroundAsset = fileURLToPath(
|
||||
new URL('../src/assets/images/hero-3d-banner.png', import.meta.url)
|
||||
)
|
||||
const capabilityGlassAsset = fileURLToPath(
|
||||
new URL('../src/assets/personal-workbench-card-glass-capability.webp', import.meta.url)
|
||||
)
|
||||
@@ -48,14 +45,41 @@ const panelGlassAsset = fileURLToPath(
|
||||
new URL('../src/assets/personal-workbench-card-glass-panel.webp', import.meta.url)
|
||||
)
|
||||
|
||||
test('workbench assistant greets the current employee without the old helper tag', () => {
|
||||
test('traditional workbench uses compact real reimbursement trend chart instead of assistant composer', () => {
|
||||
assert.doesNotMatch(workbench, /assistant-tag/)
|
||||
assert.doesNotMatch(workbench, /AI 报销助手/)
|
||||
assert.match(workbench, /\{\{ typedTitlePrefix \}\}<span v-if="titleTypingDone">小财管家<\/span>/)
|
||||
assert.match(workbench, /const heroTitleText = computed\(\(\) => `嗨,\$\{displayUserName\.value\},我是您的 `\)/)
|
||||
assert.match(workbench, /placeholder="一次性描述申请、报销和附件处理事项,小财管家会先拆解再执行\.\.\."/)
|
||||
assert.doesNotMatch(workbench, /class="panel assistant-hero workbench-trend-hero"/)
|
||||
assert.match(workbench, /class="workbench-trend-card"/)
|
||||
assert.match(workbench, /报销趋势/)
|
||||
assert.match(workbench, /import TrendChart from '\.\.\/charts\/TrendChart\.vue'/)
|
||||
assert.match(workbench, /<TrendChart[\s\S]*mode="compareAmount"/)
|
||||
assert.match(workbench, /:labels="reimbursementTrendLabels"/)
|
||||
assert.match(workbench, /:claim-amount="reimbursementTrendAmounts"/)
|
||||
assert.match(workbench, /:comparison-amount="reimbursementTrendPreviousAmounts"/)
|
||||
assert.match(workbench, /compact/)
|
||||
assert.match(workbench, /与分析看板同源/)
|
||||
assert.doesNotMatch(workbench, /class="trend-chart-svg"/)
|
||||
assert.doesNotMatch(workbench, /currentTrendPath/)
|
||||
assert.doesNotMatch(workbench, /comparisonTrendPath/)
|
||||
assert.doesNotMatch(workbench, /reimbursementTrendPoints/)
|
||||
assert.doesNotMatch(workbench, /FALLBACK_REIMBURSEMENT_TREND_ROWS/)
|
||||
assert.doesNotMatch(workbench, /assistant-composer/)
|
||||
assert.doesNotMatch(workbench, /textarea/)
|
||||
assert.doesNotMatch(workbench, /quick-prompts/)
|
||||
assert.doesNotMatch(workbench, /useWorkbenchComposerDate/)
|
||||
assert.match(workbench, /const displayUserName = computed/)
|
||||
assert.match(workbench, /user\.name/)
|
||||
assert.match(workbenchStyles, /--hero-title-size:\s*34px;/)
|
||||
assert.match(workbenchStyles, /--trend-card-min-height:\s*260px;/)
|
||||
assert.match(workbenchStyles, /\.workbench\s*\{[\s\S]*display:\s*flex;[\s\S]*flex-direction:\s*column;/)
|
||||
assert.match(workbenchStyles, /\.workbench-trend-hero\s*\{[\s\S]*height:\s*var\(--trend-card-min-height\);[\s\S]*min-height:\s*0;/)
|
||||
assert.match(workbenchStyles, /\.workbench-trend-card\s*\{[\s\S]*grid-template-columns:\s*minmax\(200px,\s*0\.28fr\) minmax\(0,\s*1fr\);/)
|
||||
assert.match(workbenchStyles, /\.workbench-trend-card\s*\{[\s\S]*height:\s*100%;[\s\S]*min-height:\s*0;/)
|
||||
assert.match(workbenchStyles, /\.workbench-trend-card\s*\{[\s\S]*background:\s*transparent;[\s\S]*box-shadow:\s*none;/)
|
||||
assert.match(workbenchStyles, /\.trend-total\s*\{[\s\S]*font-size:\s*clamp\(38px,\s*3\.3vw,\s*54px\);/)
|
||||
assert.match(workbenchStyles, /\.capability-grid\s*\{[\s\S]*flex:\s*0 0 var\(--capability-row-height\);/)
|
||||
assert.match(workbenchStyles, /\.workbench-content-grid\s*\{[\s\S]*flex:\s*1 1 auto;/)
|
||||
assert.match(workbenchStyles, /\.trend-chart-panel\s*\{[\s\S]*min-height:\s*0;/)
|
||||
})
|
||||
|
||||
test('workbench capability cards open assistant without injecting canned prompts', () => {
|
||||
@@ -91,17 +115,22 @@ test('workbench capability cards keep user-entered context only', () => {
|
||||
assert.equal(payload.files, files)
|
||||
})
|
||||
|
||||
test('workbench hero uses theme-tintable background image', () => {
|
||||
assert.match(workbench, /hero-3d-banner\.png/)
|
||||
test('workbench trend panel is a single compact surface without background art overlay', () => {
|
||||
assert.match(workbench, /class="workbench-trend-card"/)
|
||||
assert.doesNotMatch(workbench, /hero-3d-banner\.png/)
|
||||
assert.doesNotMatch(workbench, /personal-workbench-hero-bg-theme-base\.(webp|png)/)
|
||||
assert.match(workbench, /--assistant-bg-image.*workbenchHeroBackground/)
|
||||
assert.match(workbenchStyles, /--assistant-theme-tint:[\s\S]*--theme-primary-rgb/)
|
||||
assert.match(workbenchStyles, /url\("\.\.\/\.\.\/images\/workbench-hero-right-bg\.png"\) var\(--assistant-bg-position\) \/ var\(--assistant-decor-width\) auto no-repeat/)
|
||||
assert.match(workbenchStyles, /\.assistant-hero::after\s*\{[\s\S]*content:\s*"";/)
|
||||
assert.match(workbenchResponsiveStyles, /--assistant-bg-position:\s*right center;/)
|
||||
assert.doesNotMatch(workbenchStyles, /url\("\.\.\/\.\.\/images\/workbench-hero-right-bg\.png"\)/)
|
||||
assert.doesNotMatch(workbenchStyles, /\.assistant-hero::after/)
|
||||
assert.doesNotMatch(workbenchResponsiveStyles, /--assistant-bg-position/)
|
||||
assert.doesNotMatch(workbenchResponsiveStyles, /\.assistant-hero/)
|
||||
assert.doesNotMatch(workbenchResponsiveStyles, /homepage_backgraound/)
|
||||
assert.ok(statSync(heroBackgroundAsset).size > 1024)
|
||||
assert.ok(statSync(heroBackgroundAsset).size < 600 * 1024)
|
||||
})
|
||||
|
||||
test('workbench hero does not own the global AI mode switch', () => {
|
||||
assert.doesNotMatch(workbench, /workbench-mode-switch/)
|
||||
assert.doesNotMatch(workbench, /ai-mode-toggle/)
|
||||
assert.doesNotMatch(workbenchStyles, /workbench-mode-switch/)
|
||||
assert.doesNotMatch(workbenchResponsiveStyles, /workbench-mode-switch/)
|
||||
})
|
||||
|
||||
test('workbench cards use layered glass material instead of texture-led cards', () => {
|
||||
@@ -141,13 +170,16 @@ test('workbench cards use layered glass material instead of texture-led cards',
|
||||
assert.ok(statSync(panelGlassAsset).size < 24 * 1024)
|
||||
})
|
||||
|
||||
test('workbench submit shows intent recognition feedback before assistant opens', () => {
|
||||
assert.match(workbench, /class="assistant-intent-status"/)
|
||||
assert.match(workbench, /aria-live="polite"/)
|
||||
assert.match(workbench, /正在识别意图,准备进入对应助手/)
|
||||
assert.match(workbench, /startPendingAction\('intent'\)/)
|
||||
assert.match(workbench, /if \(open\) \{\s*clearPendingAction\(\)/)
|
||||
assert.match(workbench, /:readonly="isComposerPending"/)
|
||||
test('traditional workbench no longer keeps composer pending state', () => {
|
||||
assert.doesNotMatch(workbench, /class="assistant-intent-status"/)
|
||||
assert.doesNotMatch(workbench, /正在识别意图,准备进入对应助手/)
|
||||
assert.doesNotMatch(workbench, /startPendingAction\('intent'\)/)
|
||||
assert.doesNotMatch(workbench, /isComposerPending/)
|
||||
assert.doesNotMatch(workbench, /assistantDraft/)
|
||||
assert.doesNotMatch(workbench, /fetchLatestConversation/)
|
||||
assert.doesNotMatch(workbench, /clearUserConversations/)
|
||||
assert.match(workbench, /function openCapabilityAssistant\(item\)/)
|
||||
assert.match(workbench, /buildWorkbenchCapabilityAssistantPayload\(item,\s*buildAssistantPayload\(\)\)/)
|
||||
})
|
||||
|
||||
test('workbench document progress has range filter, document types and empty state', () => {
|
||||
|
||||
@@ -8,20 +8,26 @@ const responsiveStyles = readFileSync(
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('personal workbench compacts hero input and capability cards on laptop screens', () => {
|
||||
test('personal workbench compacts trend chart 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;/)
|
||||
assert.match(responsiveStyles, /--hero-title-size:\s*30px;/)
|
||||
assert.match(responsiveStyles, /--trend-card-min-height:\s*232px;/)
|
||||
assert.match(responsiveStyles, /--capability-row-height:\s*102px;/)
|
||||
assert.match(responsiveStyles, /\.workbench-trend-hero\s*\{[\s\S]*padding:\s*24px 18px 10px 18px;/)
|
||||
assert.match(responsiveStyles, /\.trend-summary-panel h1\s*\{[\s\S]*font-size:\s*var\(--hero-title-size\);/)
|
||||
assert.match(responsiveStyles, /\.workbench-trend-card\s*\{[\s\S]*min-height:\s*0;/)
|
||||
assert.match(responsiveStyles, /\.trend-chart-panel\s*\{[\s\S]*min-height:\s*0;/)
|
||||
assert.match(responsiveStyles, /\.trend-total\s*\{[\s\S]*font-size:\s*42px;/)
|
||||
assert.match(responsiveStyles, /\.trend-chart-head strong\s*\{[\s\S]*font-size:\s*14px;/)
|
||||
assert.match(responsiveStyles, /\.trend-summary-panel small\s*\{[\s\S]*display:\s*none;/)
|
||||
assert.doesNotMatch(responsiveStyles, /\.assistant-hero/)
|
||||
assert.doesNotMatch(responsiveStyles, /--assistant-bg-position/)
|
||||
assert.match(responsiveStyles, /\.capability-card\s*\{[\s\S]*grid-template-columns:\s*40px minmax\(0,\s*1fr\) 16px;[\s\S]*padding:\s*15px 14px 15px 18px;/)
|
||||
assert.doesNotMatch(responsiveStyles, /--composer-min-height/)
|
||||
assert.match(responsiveStyles, /@media \(max-width: 760px\)[\s\S]*\.workbench\s*\{[\s\S]*height:\s*auto;/)
|
||||
assert.match(responsiveStyles, /@media \(max-width: 760px\)[\s\S]*\.trend-summary-panel\s*\{[\s\S]*transform:\s*none;/)
|
||||
assert.doesNotMatch(responsiveStyles, /grid-template-rows:\s*auto var\(--capability-row-height\)/)
|
||||
})
|
||||
|
||||
@@ -13,14 +13,20 @@ const sidebar = readFileSync(
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const sidebarStyles = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/components/sidebar-rail.css', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const appStyles = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/app.css', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('sidebar supports smooth animated collapsed layout', () => {
|
||||
assert.match(appShell, /sidebarCollapsed = ref\(true\)/)
|
||||
assert.match(appShell, /:class="\{ 'sidebar-collapsed': sidebarCollapsed \}"/)
|
||||
assert.match(appShell, /sidebarCollapsed = ref\(false\)/)
|
||||
assert.match(appShell, /'sidebar-collapsed': sidebarCollapsed/)
|
||||
assert.match(appShell, /'workbench-ai-sidebar-active': isAiShellMode/)
|
||||
assert.match(appShell, /:collapsed="sidebarCollapsed"/)
|
||||
assert.match(appShell, /class="app-sidebar"/)
|
||||
assert.match(appShell, /@toggle-collapse="toggleSidebarCollapsed"/)
|
||||
@@ -32,9 +38,15 @@ test('sidebar supports smooth animated collapsed layout', () => {
|
||||
assert.match(sidebar, /rail-collapsed/)
|
||||
assert.match(sidebar, /折叠侧边栏/)
|
||||
assert.match(sidebar, /展开侧边栏/)
|
||||
assert.match(sidebar, /--rail-motion-duration: 320ms/)
|
||||
assert.match(sidebar, /opacity var\(--rail-fade-duration\)/)
|
||||
assert.match(sidebar, /class="user-actions"/)
|
||||
assert.match(sidebar, /class="user-action user-logout"/)
|
||||
assert.doesNotMatch(sidebar, /mdi-chevron-up/)
|
||||
assert.match(sidebarStyles, /--rail-motion-duration: 220ms/)
|
||||
assert.match(sidebarStyles, /opacity var\(--rail-fade-duration\)/)
|
||||
assert.match(sidebarStyles, /\.rail-user\s*\{[\s\S]*height:\s*72px;[\s\S]*grid-template-columns:\s*42px minmax\(0,\s*1fr\) 44px;/)
|
||||
assert.match(sidebarStyles, /\.user-actions\s*\{[\s\S]*grid-template-columns:\s*44px;/)
|
||||
|
||||
assert.match(appStyles, /--sidebar-expanded-width:\s*304px/)
|
||||
assert.match(appStyles, /--sidebar-collapsed-width: 64px/)
|
||||
assert.match(appStyles, /\.app-sidebar\s*\{[^}]*transition:\s*width var\(--sidebar-motion\)/)
|
||||
assert.match(appStyles, /\.app\.sidebar-collapsed\s+\.app-sidebar\s*\{\s*width:\s*var\(--sidebar-collapsed-width\)/)
|
||||
|
||||
90
web/tests/steward-field-completion-model.test.mjs
Normal file
90
web/tests/steward-field-completion-model.test.mjs
Normal file
@@ -0,0 +1,90 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
buildStewardFieldCompletionContinuation,
|
||||
buildStewardFieldCompletionRawText,
|
||||
resolveStewardRuntimeFieldCompletion
|
||||
} from '../src/views/scripts/stewardFieldCompletionModel.js'
|
||||
|
||||
test('steward field completion maps ontology travel type and anchors reason update', () => {
|
||||
const continuation = buildStewardFieldCompletionContinuation(
|
||||
{
|
||||
currentTask: {
|
||||
summary: '2月20-23日上海出差',
|
||||
ontology_fields: {
|
||||
expense_type: 'travel',
|
||||
time_range: '2026-02-20 至 2026-02-23',
|
||||
location: '上海市'
|
||||
},
|
||||
missing_fields: ['reason']
|
||||
}
|
||||
},
|
||||
'reason',
|
||||
'辅助国网仿生产服务器部署'
|
||||
)
|
||||
|
||||
const rawText = buildStewardFieldCompletionRawText({
|
||||
preview: {
|
||||
fields: {
|
||||
applicationType: '',
|
||||
time: '2026-02-20 至 2026-02-23',
|
||||
location: '上海市',
|
||||
reason: '',
|
||||
days: '4天',
|
||||
transportMode: '火车'
|
||||
}
|
||||
},
|
||||
fieldKey: 'reason',
|
||||
fieldLabel: '事由',
|
||||
value: '辅助国网仿生产服务器部署',
|
||||
continuation
|
||||
})
|
||||
|
||||
assert.equal(continuation.currentTask.ontology_fields.reason, '辅助国网仿生产服务器部署')
|
||||
assert.deepEqual(continuation.currentTask.missing_fields, [])
|
||||
assert.match(rawText, /申请类型:差旅费用申请/)
|
||||
assert.doesNotMatch(rawText, /申请类型:travel/)
|
||||
assert.match(rawText, /用户已补充:事由:辅助国网仿生产服务器部署。/)
|
||||
assert.match(rawText, /当前申请单字段的补充\/更新/)
|
||||
assert.match(rawText, /不是新建申请或切换任务/)
|
||||
assert.match(rawText, /不要把它改判为新的 IT 部署申请/)
|
||||
})
|
||||
|
||||
test('steward runtime treats bare reason reply as current application field completion', () => {
|
||||
const decision = resolveStewardRuntimeFieldCompletion(
|
||||
'辅助国网仿生产服务器部署',
|
||||
{
|
||||
waiting_for: 'application_field_completion',
|
||||
pending_application: {
|
||||
message_id: 'application-preview-1',
|
||||
ready_to_submit: false,
|
||||
missing_fields: ['事由']
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
assert.deepEqual(decision, {
|
||||
next_action: 'fill_current_application_field',
|
||||
target_message_id: 'application-preview-1',
|
||||
field_key: 'reason',
|
||||
field_label: '事由',
|
||||
field_value: '辅助国网仿生产服务器部署'
|
||||
})
|
||||
})
|
||||
|
||||
test('steward runtime keeps multi-field free text ambiguous for normal decision flow', () => {
|
||||
assert.equal(
|
||||
resolveStewardRuntimeFieldCompletion(
|
||||
'辅助国网仿生产服务器部署',
|
||||
{
|
||||
waiting_for: 'application_field_completion',
|
||||
pending_application: {
|
||||
message_id: 'application-preview-1',
|
||||
missing_fields: ['地点', '事由']
|
||||
}
|
||||
}
|
||||
),
|
||||
null
|
||||
)
|
||||
})
|
||||
73
web/tests/steward-plan-message-copy.test.mjs
Normal file
73
web/tests/steward-plan-message-copy.test.mjs
Normal file
@@ -0,0 +1,73 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
buildStewardPlanMessageText,
|
||||
buildStewardSuggestedActions
|
||||
} from '../src/views/scripts/stewardPlanModel.js'
|
||||
|
||||
test('steward plan summary uses warm guidance copy for application flow', () => {
|
||||
const message = buildStewardPlanMessageText({
|
||||
tasks: [
|
||||
{
|
||||
task_id: 'task-1',
|
||||
task_type: 'expense_application',
|
||||
title: '费用申请 差旅',
|
||||
assigned_agent: 'application_assistant',
|
||||
confirmation_required: true
|
||||
}
|
||||
],
|
||||
next_action: 'confirm_create_application'
|
||||
})
|
||||
|
||||
assert.match(message, /我先帮你把步骤理清楚/)
|
||||
assert.match(message, /我先看了一下,你这次主要是 \*\*1 个事项\*\*/)
|
||||
assert.match(message, /为了不让步骤混在一起/)
|
||||
assert.match(message, /我会请申请助手先把申请单草稿整理出来/)
|
||||
assert.match(message, /你看这个顺序是否合适/)
|
||||
assert.match(message, /需要补充的信息会在具体步骤里再温和提醒你/)
|
||||
assert.doesNotMatch(message, /我会这样推进/)
|
||||
assert.doesNotMatch(message, /不会一次性把所有动作都执行掉/)
|
||||
assert.doesNotMatch(message, /交给申请助手生成申请单核对结果/)
|
||||
})
|
||||
|
||||
test('steward plan summary guides bare reimbursement intent into scene selection', () => {
|
||||
const plan = {
|
||||
tasks: [
|
||||
{
|
||||
task_id: 'task-reim-1',
|
||||
task_type: 'reimbursement',
|
||||
title: '费用报销 1',
|
||||
assigned_agent: 'reimbursement_assistant',
|
||||
confirmation_required: true,
|
||||
ontology_fields: {
|
||||
expense_type: 'other',
|
||||
reason: '我要报销'
|
||||
},
|
||||
missing_fields: ['time_range', 'reason']
|
||||
}
|
||||
],
|
||||
confirmation_groups: [
|
||||
{
|
||||
confirmation_id: 'confirm-task-reim-1',
|
||||
action_type: 'confirm_create_reimbursement_draft',
|
||||
target_task_id: 'task-reim-1'
|
||||
}
|
||||
],
|
||||
next_action: 'confirm_task'
|
||||
}
|
||||
|
||||
const message = buildStewardPlanMessageText(plan)
|
||||
|
||||
assert.match(message, /我来带你发起报销/)
|
||||
assert.match(message, /你现在只说了要报销/)
|
||||
assert.match(message, /先选报销场景/)
|
||||
assert.match(message, /差旅费、交通费、住宿费/)
|
||||
assert.doesNotMatch(message, /步骤混在一起/)
|
||||
assert.doesNotMatch(message, /核对“费用报销 1”/)
|
||||
|
||||
const [action] = buildStewardSuggestedActions(plan)
|
||||
assert.equal(action.label, '确定,选择报销场景')
|
||||
assert.match(action.description, /先进入报销助手选择具体费用类型/)
|
||||
assert.equal(action.payload.carry_text, '我要报销')
|
||||
})
|
||||
@@ -2,9 +2,44 @@ import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
buildStewardPlanMessageText,
|
||||
buildStewardSuggestedActions
|
||||
} from '../src/views/scripts/stewardPlanModel.js'
|
||||
|
||||
test('steward pending flow confirmation renders extracted fields as table', () => {
|
||||
const message = buildStewardPlanMessageText({
|
||||
plan_id: 'steward-plan-pending-flow',
|
||||
plan_status: 'needs_flow_confirmation',
|
||||
next_action: 'confirm_flow',
|
||||
pending_flow_confirmation: {
|
||||
status: 'pending',
|
||||
reason: '已先查询可关联申请单,当前仍需确认下一步。',
|
||||
candidate_flows: [
|
||||
{
|
||||
flow_id: 'travel_application',
|
||||
label: '先发起出差申请',
|
||||
confidence: 0.82,
|
||||
ontology_fields: {
|
||||
time_range: '2026-02-20',
|
||||
location: '上海',
|
||||
expense_type: 'travel',
|
||||
reason: '辅助国网仿生产服务器部署'
|
||||
},
|
||||
missing_fields: ['transport_mode']
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
assert.match(message, /\| 字段 \| 内容 \|/)
|
||||
assert.match(message, /\| 费用类型 \| 差旅 \|/)
|
||||
assert.match(message, /\| 发生时间 \| 2026-02-20 \|/)
|
||||
assert.match(message, /\| 地点 \| 上海 \|/)
|
||||
assert.match(message, /\| 事由 \| 辅助国网仿生产服务器部署 \|/)
|
||||
assert.doesNotMatch(message, /已提取到:\*\*/)
|
||||
assert.match(message, /请先点击下方/)
|
||||
})
|
||||
|
||||
test('steward pending flow confirmation builds candidate actions', () => {
|
||||
const actions = buildStewardSuggestedActions({
|
||||
plan_id: 'steward-plan-pending-flow',
|
||||
|
||||
@@ -22,7 +22,7 @@ const OFF_TOPIC_PLAN = {
|
||||
attachment_groups: [],
|
||||
confirmation_groups: [],
|
||||
candidate_flows: [],
|
||||
summary: '这看起来跟财务任务没什么关系...',
|
||||
summary: '很抱歉主人,目前小财管家只能帮您整理**费用申请**和**费用报销**这两类事项。',
|
||||
suggested_prompts: [
|
||||
'我想要申请明天去北京出差3天,支撑客户现场实施',
|
||||
'我要报销上周去上海的高铁票',
|
||||
@@ -64,24 +64,60 @@ test('isOffTopicStewardPlan returns true only for off_topic plan status', () =>
|
||||
assert.equal(isOffTopicStewardPlan({}), false)
|
||||
})
|
||||
|
||||
test('buildStewardPlanMessageText renders friendly off_topic guidance', () => {
|
||||
test('buildStewardPlanMessageText renders backend-provided off_topic summary verbatim', () => {
|
||||
// off_topic 文案由后端(含 LLM)生成,前端只负责透传,不再拼接标题/引导
|
||||
const text = buildStewardPlanMessageText(OFF_TOPIC_PLAN)
|
||||
assert.match(text, /小财管家没看懂这件事/)
|
||||
assert.equal(text, OFF_TOPIC_PLAN.summary)
|
||||
// 推荐话术本身不在正文里展示,而是作为按钮单独渲染,避免重复。
|
||||
for (const prompt of OFF_TOPIC_PLAN.suggested_prompts) {
|
||||
assert.equal(text.includes(prompt), false, `正文不应包含推荐话术:${prompt}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('buildStewardPlanMessageText keeps off_topic branch ahead of pending flow branch', () => {
|
||||
// 即使 summary 缺省,也走 off_topic 分支而非默认任务文案
|
||||
const text = buildStewardPlanMessageText({
|
||||
plan_id: 'p-off-topic-default',
|
||||
test('buildStewardPlanMessageText adapts greeting vs meaningless vs off_business summaries', () => {
|
||||
// 问候场景:礼貌回应主人
|
||||
const greetingText = buildStewardPlanMessageText({
|
||||
plan_id: 'p-greeting',
|
||||
plan_status: 'off_topic',
|
||||
next_action: 'none',
|
||||
suggested_prompts: ['申请出差']
|
||||
summary: '### 您好主人,很高兴为您服务\n\n请问您今天要办理什么业务?',
|
||||
suggested_prompts: ['我想要申请明天去北京出差3天']
|
||||
})
|
||||
assert.match(text, /小财管家没看懂这件事/)
|
||||
assert.match(greetingText, /您好主人/)
|
||||
assert.match(greetingText, /请问您今天要办理什么业务/)
|
||||
|
||||
// 无意义场景:温和解释 + 引导换种说法
|
||||
const meaninglessText = buildStewardPlanMessageText({
|
||||
plan_id: 'p-meaningless',
|
||||
plan_status: 'off_topic',
|
||||
next_action: 'none',
|
||||
summary: '### 这句话我暂时没识别到财务事项\n\n很抱歉主人,目前小财管家只能帮您整理**费用申请**和**费用报销**。',
|
||||
suggested_prompts: ['我想要申请明天去北京出差3天']
|
||||
})
|
||||
assert.match(meaninglessText, /这句话我暂时没识别到财务事项/)
|
||||
assert.match(meaninglessText, /很抱歉主人/)
|
||||
|
||||
// 有意义但非业务场景:LLM 生成的文案(这里 mock 模拟)
|
||||
const llmText = '### 抱歉主人,这句话我暂时帮不上忙\n\n主人聊的是天气,小财管家目前只能帮您整理**费用申请**和**费用报销**。'
|
||||
const offBusinessText = buildStewardPlanMessageText({
|
||||
plan_id: 'p-off-business',
|
||||
plan_status: 'off_topic',
|
||||
next_action: 'none',
|
||||
summary: llmText,
|
||||
suggested_prompts: ['我想要申请明天去北京出差3天']
|
||||
})
|
||||
assert.equal(offBusinessText, llmText)
|
||||
})
|
||||
|
||||
test('buildStewardPlanMessageText falls back to client template when summary is missing', () => {
|
||||
// 后端 summary 缺失时,前端有兜底文案保证体验不空白
|
||||
const text = buildStewardPlanMessageText({
|
||||
plan_id: 'p-empty',
|
||||
plan_status: 'off_topic',
|
||||
next_action: 'none',
|
||||
suggested_prompts: []
|
||||
})
|
||||
assert.match(text, /这句话我暂时没识别到财务事项/)
|
||||
assert.match(text, /费用申请.*费用报销|费用报销.*费用申请/)
|
||||
})
|
||||
|
||||
|
||||
39
web/tests/topbar-ai-mode-switch.test.mjs
Normal file
39
web/tests/topbar-ai-mode-switch.test.mjs
Normal file
@@ -0,0 +1,39 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const topbar = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/layout/TopBar.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const topbarStyles = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/components/top-bar.css', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('workbench topbar places the colorful AI mode button after the company switcher', () => {
|
||||
assert.match(topbar, /<button class="company-switcher"[\s\S]*aria-label="切换公司"[\s\S]*<\/button>\s*<button[\s\S]*class="topbar-ai-mode-toggle"/)
|
||||
assert.match(topbar, /class="topbar-ai-mode-toggle__glyph">AI<\/span>/)
|
||||
assert.match(topbar, /@click="toggleTopbarWorkbenchMode"/)
|
||||
assert.match(topbar, /:aria-pressed="isTopbarAiMode"/)
|
||||
assert.match(topbar, /:title="topbarWorkbenchModeTitle"/)
|
||||
assert.match(topbar, /workbenchMode:\s*\{[\s\S]*type:\s*String,[\s\S]*default:\s*'traditional'/)
|
||||
assert.doesNotMatch(topbar, /const topbarWorkbenchMode = ref/)
|
||||
assert.match(topbar, /const isTopbarAiMode = computed\(\(\) => props\.workbenchMode === 'ai'\)/)
|
||||
assert.match(topbar, /const topbarWorkbenchModeTitle = computed/)
|
||||
assert.match(topbar, /const showAiModeUtilityActions = computed\(\(\) => isTopbarAiMode\.value && !isWorkbench\.value\)/)
|
||||
assert.match(topbar, /<div v-if="showAiModeUtilityActions" class="topbar-utility-actions"/)
|
||||
assert.match(topbar, /function toggleTopbarWorkbenchMode\(\)/)
|
||||
assert.match(topbar, /emit\('toggleWorkbenchMode'\)/)
|
||||
})
|
||||
|
||||
test('topbar AI mode button keeps a circular colorful text treatment', () => {
|
||||
assert.match(topbarStyles, /\.topbar-ai-mode-toggle\s*\{[\s\S]*width:\s*38px;[\s\S]*height:\s*38px;[\s\S]*border-radius:\s*50%;/)
|
||||
assert.match(topbarStyles, /\.topbar-ai-mode-toggle\s*\{[\s\S]*conic-gradient\(from 210deg,[\s\S]*border-box;/)
|
||||
assert.match(topbarStyles, /\.topbar-ai-mode-toggle__glyph\s*\{[\s\S]*linear-gradient\(135deg,[\s\S]*background-clip:\s*text;[\s\S]*letter-spacing:\s*0;/)
|
||||
assert.match(topbarStyles, /\.topbar-ai-mode-toggle:hover,[\s\S]*\.topbar-ai-mode-toggle:focus-visible\s*\{[\s\S]*transform:\s*translateY\(-1px\);/)
|
||||
assert.match(topbarStyles, /\.topbar-utility-actions\s*\{[\s\S]*display:\s*inline-flex;[\s\S]*justify-content:\s*flex-end;/)
|
||||
assert.match(topbarStyles, /@media \(max-width: 960px\)[\s\S]*\.topbar-ai-mode-toggle\s*\{[\s\S]*width:\s*34px;[\s\S]*height:\s*34px;/)
|
||||
assert.match(topbarStyles, /@media \(max-width: 640px\)[\s\S]*\.topbar-ai-mode-toggle\s*\{[\s\S]*flex:\s*0 0 34px;/)
|
||||
})
|
||||
@@ -454,6 +454,8 @@ test('guided flow is local until final confirmation or collected query handoff',
|
||||
assert.match(createViewScript, /if \(await handleGuidedComposerSubmit\(options\)\) \{[\s\S]*return null[\s\S]*\}[\s\S]*return submitComposerInternal\(options\)/)
|
||||
assert.match(suggestedActionsScript, /ASSISTANT_SCOPE_ACTION_SWITCH/)
|
||||
assert.match(suggestedActionsScript, /actionPayload\.carry_text/)
|
||||
assert.match(createViewScript, /targetSessionType === SESSION_TYPE_EXPENSE[\s\S]*carryText === '我要报销'[\s\S]*pushExpenseSceneSelectionPrompt\(carryText\)/)
|
||||
assert.match(suggestedActionsScript, /targetSessionType === SESSION_TYPE_EXPENSE[\s\S]*carryText === '我要报销'[\s\S]*pushExpenseSceneSelectionPrompt\(carryText\)/)
|
||||
assert.match(submitComposerScript, /resolveAssistantScopeGuard/)
|
||||
assert.match(submitComposerScript, /skipScopeGuard/)
|
||||
assert.match(guidedFlowScript, /submitExistingComposer\(submitOptions\)/)
|
||||
|
||||
@@ -300,19 +300,34 @@ test('risk cards carry structured business stage for approval advice filtering',
|
||||
test('stage risk advice card focuses on document risks without profile or budget boards', () => {
|
||||
assert.match(stageRiskAdviceCard, /employee-risk-decision-panel/)
|
||||
assert.match(stageRiskAdviceCard, /综合审核结论/)
|
||||
assert.match(stageRiskAdviceCard, /建议结论/)
|
||||
assert.match(stageRiskAdviceCard, /\{\{ decisionAction \}\}/)
|
||||
assert.match(stageRiskAdviceCard, /是否建议通过/)
|
||||
assert.match(stageRiskAdviceCard, /\{\{ decisionBadgeLabel \}\}/)
|
||||
assert.match(stageRiskAdviceCard, /employee-risk-review-summary/)
|
||||
assert.match(stageRiskAdviceCard, /reviewSummaryItems/)
|
||||
assert.match(stageRiskAdviceCard, /风险概览/)
|
||||
assert.match(stageRiskAdviceCard, /重点依据/)
|
||||
assert.match(stageRiskAdviceCard, /审核建议/)
|
||||
assert.match(stageRiskAdviceCard, /stageRiskFactSummary/)
|
||||
assert.match(stageRiskAdviceCard, /stageReviewBasisSummary/)
|
||||
assert.match(stageRiskAdviceCard, /compactEvidenceItems/)
|
||||
assert.match(stageRiskAdviceCard, /stageBasisTitle/)
|
||||
assert.match(stageRiskAdviceCard, /stageBasisHint/)
|
||||
assert.match(stageRiskAdviceCard, /employee-risk-profile-section/)
|
||||
assert.match(stageRiskAdviceCard, /employee-risk-profile-list/)
|
||||
assert.match(stageRiskAdviceCard, /<details/)
|
||||
assert.match(stageRiskAdviceCard, /申请审核建议/)
|
||||
assert.match(stageRiskAdviceCard, /AI建议/)
|
||||
assert.doesNotMatch(stageRiskAdviceCard, /报销审核建议/)
|
||||
assert.match(stageRiskAdviceCard, /classifyReimbursementRiskCards/)
|
||||
assert.match(stageRiskAdviceCard, /stripEmbeddedExplanationText/)
|
||||
assert.match(stageRiskAdviceCard, /if \(summary\) \{[\s\S]*return \[`已补充异常说明:\$\{summary\}`\]/)
|
||||
assert.match(stageRiskAdviceCard, /employee-risk-tone-pill/)
|
||||
assert.match(stageRiskAdviceStyles, /\.employee-risk-decision-panel \{[\s\S]*grid-template-columns: minmax\(0, 1fr\) minmax\(220px, 32%\);/)
|
||||
assert.match(stageRiskAdviceStyles, /\.employee-risk-decision-panel \{[\s\S]*grid-template-columns: minmax\(0, 1\.15fr\) minmax\(220px, \.85fr\);/)
|
||||
assert.match(stageRiskAdviceStyles, /\.employee-risk-review-summary \{[\s\S]*display: flex;[\s\S]*flex-wrap: wrap;/)
|
||||
assert.match(stageRiskAdviceStyles, /\.employee-risk-review-item \{[\s\S]*flex: 1 1 180px;/)
|
||||
assert.match(stageRiskAdviceStyles, /\.employee-risk-profile-list \{[\s\S]*grid-template-columns: 1fr;/)
|
||||
assert.match(stageRiskAdviceStyles, /\.employee-risk-evidence-row summary \{[\s\S]*cursor: pointer;/)
|
||||
assert.match(stageRiskAdviceStyles, /\.employee-risk-evidence-title::after \{[\s\S]*content: '展开';/)
|
||||
assert.match(stageRiskAdviceStyles, /\.employee-risk-evidence-row li \{[\s\S]*white-space: normal;/)
|
||||
assert.doesNotMatch(stageRiskAdviceStyles, /grid-row: span 2/)
|
||||
assert.match(stageRiskAdviceCard, /建议退回补充票据、行程说明或超标原因/)
|
||||
@@ -320,8 +335,8 @@ test('stage risk advice card focuses on document risks without profile or budget
|
||||
assert.match(stageRiskAdviceCard, /请核对已补充说明是否覆盖风险点/)
|
||||
assert.match(stageRiskAdviceCard, /已补充异常说明/)
|
||||
assert.match(stageRiskAdviceCard, /可按权限继续审批/)
|
||||
assert.match(stageRiskAdviceCard, /申请单风险依据/)
|
||||
assert.match(stageRiskAdviceCard, /报销单风险依据/)
|
||||
assert.match(stageRiskAdviceCard, /申请单关键依据/)
|
||||
assert.match(stageRiskAdviceCard, /报销单关键依据/)
|
||||
assert.doesNotMatch(stageRiskAdviceCard, /人员行为画像/)
|
||||
assert.doesNotMatch(stageRiskAdviceCard, /部门预算执行/)
|
||||
assert.doesNotMatch(stageRiskAdviceCard, /title: '说明与佐证'/)
|
||||
@@ -631,7 +646,9 @@ test('AI advice template renders grouped section titles with completion before r
|
||||
assert.match(detailViewScript, /const showCompactSafeAdvice = computed/)
|
||||
assert.match(detailViewScript, /const showAiAdvicePanel = computed\(\(\) => \(/)
|
||||
assert.match(detailViewScript, /isCurrentApplicant\.value && !isApplicationDocument\.value && hasVisibleRiskCards\.value/)
|
||||
assert.match(detailViewScript, /return '报销风险提示'/)
|
||||
assert.match(detailViewScript, /return '风险提示'/)
|
||||
assert.match(detailViewScript, /return isEditableRequest\.value \? 'AI建议' : '风险提示'/)
|
||||
assert.doesNotMatch(detailViewScript, /return '报销风险提示'/)
|
||||
assert.match(detailViewScript, /canViewApprovalRiskAdvice\.value && aiAdvice\.value\.riskCards\.length > 0/)
|
||||
assert.match(detailViewScript, /buildTravelReceiptMaterialPrompts\(request\.value, expenseItems\.value\)/)
|
||||
assert.match(detailViewScript, /buildEmployeeProfileAdviceItems\(employeeRiskProfile\.value\)/)
|
||||
@@ -648,9 +665,13 @@ test('AI advice template renders grouped section titles with completion before r
|
||||
)
|
||||
})
|
||||
|
||||
test('AI advice risk section uses compact card styling hooks', () => {
|
||||
test('AI advice risk section keeps compact risk prompt styling', () => {
|
||||
assert.match(detailViewTemplate, /class="\['risk-advice-card', card\.tone, \{ 'is-highlighted': isHighlightedRiskCard\(card\) \}\]"/)
|
||||
assert.match(detailViewTemplate, /class="risk-advice-point"/)
|
||||
assert.match(detailViewTemplate, /class="risk-advice-compact-meta"/)
|
||||
assert.match(detailViewTemplate, /\{\{ card\.ruleBasis\[0\] \}\}/)
|
||||
assert.doesNotMatch(detailViewTemplate, /risk-advice-detail-grid/)
|
||||
assert.doesNotMatch(detailViewTemplate, /<dt>风险事实<\/dt>/)
|
||||
assert.doesNotMatch(detailViewTemplate, /section\.hiddenCount/)
|
||||
assert.doesNotMatch(detailViewTemplate, /risk-advice-more/)
|
||||
assert.doesNotMatch(detailViewTemplate, /card\.tags\?\.length/)
|
||||
@@ -667,6 +688,7 @@ test('AI advice risk section uses compact card styling hooks', () => {
|
||||
assert.match(detailViewStyle, /\.risk-advice-card\.low/)
|
||||
assert.doesNotMatch(detailViewStyle, /\.risk-note-tag/)
|
||||
assert.match(detailViewStyle, /\.risk-advice-compact-meta span,\s*\.risk-advice-compact-meta em \{\s*margin: 0;/)
|
||||
assert.doesNotMatch(detailViewStyle, /\.risk-advice-detail-grid/)
|
||||
assert.doesNotMatch(detailViewStyle, /\.risk-advice-more/)
|
||||
})
|
||||
|
||||
|
||||
80
web/tests/workbench-ai-mode-expense-scene-action.test.mjs
Normal file
80
web/tests/workbench-ai-mode-expense-scene-action.test.mjs
Normal file
@@ -0,0 +1,80 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { buildExpenseSceneSelectionActions } from '../src/utils/expenseAssistantActions.js'
|
||||
import { buildExpenseSceneSelectionMessage } from '../src/views/scripts/travelReimbursementConversationModel.js'
|
||||
|
||||
const aiMode = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/business/PersonalWorkbenchAiMode.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('expense scene selection message asks for type first and mentions application gate', () => {
|
||||
const text = buildExpenseSceneSelectionMessage('帮我发起一笔报销,并检查需要准备哪些票据材料。')
|
||||
|
||||
assert.match(text, /先选|选择.*报销类型|报销场景/)
|
||||
assert.match(text, /差旅|招待|申请单|关联申请单/)
|
||||
})
|
||||
|
||||
test('expense scene actions mark travel and meal as requiring application', () => {
|
||||
const actions = buildExpenseSceneSelectionActions('帮我发起一笔报销,并检查需要准备哪些票据材料。')
|
||||
const travel = actions.find((action) => action.payload.expense_type === 'travel')
|
||||
const meal = actions.find((action) => action.payload.expense_type === 'meal')
|
||||
const transport = actions.find((action) => action.payload.expense_type === 'transport')
|
||||
|
||||
assert.equal(travel.payload.requires_application_before_reimbursement, true)
|
||||
assert.equal(travel.payload.next_session_type, 'application')
|
||||
assert.equal(meal.payload.requires_application_before_reimbursement, true)
|
||||
assert.equal(meal.payload.next_session_type, 'application')
|
||||
assert.equal(transport.payload.requires_application_before_reimbursement, false)
|
||||
assert.equal(transport.payload.next_session_type, 'expense')
|
||||
})
|
||||
|
||||
test('AI mode quick reimbursement card opens scene selection before steward plan', () => {
|
||||
assert.match(
|
||||
aiMode,
|
||||
/function runAiModeAction\(item\) {[\s\S]{0,220}pushInlineExpenseSceneSelectionPrompt\(item\.prompt, item\.label\)/
|
||||
)
|
||||
})
|
||||
|
||||
test('AI mode expense scene selection stays in the inline conversation without opening the create view', () => {
|
||||
assert.match(aiMode, /actionType === 'select_expense_type'/)
|
||||
assert.doesNotMatch(aiMode, /emit\('open-assistant'/)
|
||||
})
|
||||
|
||||
test('AI mode offers an inline application shortcut when no candidate application exists', () => {
|
||||
assert.match(aiMode, /!candidates\.length/)
|
||||
assert.match(aiMode, /ai_application_start_inline/)
|
||||
assert.match(aiMode, /buildRequiredApplicationMissingText/)
|
||||
assert.match(aiMode, /function startAiApplicationDraft/)
|
||||
})
|
||||
|
||||
test('AI mode steward reimbursement action opens expense scene selection locally', () => {
|
||||
assert.match(aiMode, /buildExpenseSceneSelectionMessage/)
|
||||
assert.match(aiMode, /buildExpenseSceneSelectionActions/)
|
||||
assert.match(aiMode, /SESSION_TYPE_EXPENSE/)
|
||||
assert.match(aiMode, /function pushInlineExpenseSceneSelectionPrompt/)
|
||||
assert.match(aiMode, /payload\?\.session_type[\s\S]*SESSION_TYPE_EXPENSE/)
|
||||
assert.match(aiMode, /pushInlineExpenseSceneSelectionPrompt\(carryText, action\.label\)/)
|
||||
assert.match(
|
||||
aiMode,
|
||||
/SESSION_TYPE_EXPENSE[\s\S]{0,140}pushInlineExpenseSceneSelectionPrompt\(carryText, action\.label\)[\s\S]{0,40}return/
|
||||
)
|
||||
})
|
||||
|
||||
test('AI mode attaches required application lookup result before steward planning', () => {
|
||||
assert.match(aiMode, /async function attachAiRequiredApplicationGate\(planRequest, prompt\)/)
|
||||
assert.match(aiMode, /fetchExpenseClaims\(\)/)
|
||||
assert.match(aiMode, /filterRequiredApplicationCandidates\(claims, 'travel', currentUser\.value \|\| \{\}\)/)
|
||||
assert.match(aiMode, /required_application_gate/)
|
||||
assert.match(aiMode, /await attachAiRequiredApplicationGate\(planRequest, prompt\)/)
|
||||
})
|
||||
|
||||
test('AI mode automatically continues required application gate decisions from steward plan', () => {
|
||||
assert.match(aiMode, /function continueAiRequiredApplicationGateFromPlan\(normalizedPlan\)/)
|
||||
assert.match(aiMode, /flow\.flowId === 'travel_application'[\s\S]*startAiApplicationDraft\('travel', '差旅费'/)
|
||||
assert.match(aiMode, /flow\.flowId === 'travel_reimbursement'[\s\S]*startAiExpenseDraft\('travel', '差旅费', true/)
|
||||
assert.match(aiMode, /continueAiRequiredApplicationGateFromPlan\(normalizedPlan\)/)
|
||||
})
|
||||
386
web/tests/workbench-ai-mode-switch.test.mjs
Normal file
386
web/tests/workbench-ai-mode-switch.test.mjs
Normal file
@@ -0,0 +1,386 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { 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 measureGifMotion(assetPath) {
|
||||
const script = `
|
||||
from PIL import Image, ImageSequence
|
||||
import json
|
||||
import sys
|
||||
|
||||
image = Image.open(sys.argv[1])
|
||||
frames = [frame.convert("RGB").resize((64, 64)) for frame in ImageSequence.Iterator(image)]
|
||||
|
||||
def delta(left, right):
|
||||
left_pixels = left.load()
|
||||
right_pixels = right.load()
|
||||
total = 0
|
||||
for y in range(64):
|
||||
for x in range(64):
|
||||
a = left_pixels[x, y]
|
||||
b = right_pixels[x, y]
|
||||
total += abs(a[0] - b[0]) + abs(a[1] - b[1]) + abs(a[2] - b[2])
|
||||
return total / (64 * 64 * 3)
|
||||
|
||||
adjacent = [delta(frames[index], frames[index + 1]) for index in range(len(frames) - 1)]
|
||||
adjacent_sorted = sorted(adjacent)
|
||||
median = adjacent_sorted[len(adjacent_sorted) // 2]
|
||||
print(json.dumps({
|
||||
"medianAdjacentDelta": median,
|
||||
"seamDelta": delta(frames[-1], frames[0])
|
||||
}))
|
||||
`
|
||||
return JSON.parse(execFileSync('python3', ['-', assetPath], {
|
||||
encoding: 'utf8',
|
||||
input: script
|
||||
}).trim())
|
||||
}
|
||||
|
||||
function measureGifDuration(assetPath) {
|
||||
const script = `
|
||||
from PIL import Image
|
||||
import sys
|
||||
|
||||
image = Image.open(sys.argv[1])
|
||||
total = 0
|
||||
for index in range(getattr(image, "n_frames", 1)):
|
||||
image.seek(index)
|
||||
total += image.info.get("duration", 0)
|
||||
print(total)
|
||||
`
|
||||
return Number(execFileSync('python3', ['-', assetPath], {
|
||||
encoding: 'utf8',
|
||||
input: script
|
||||
}).trim())
|
||||
}
|
||||
|
||||
function measureOrbAssetPresentation(assetPath) {
|
||||
const script = `
|
||||
from PIL import Image
|
||||
import json
|
||||
import sys
|
||||
|
||||
image = Image.open(sys.argv[1])
|
||||
frame_count = getattr(image, "n_frames", 1)
|
||||
width, height = image.size
|
||||
minimum_corner_luma = 255
|
||||
maximum_corner_luma = 0
|
||||
minimum_background_similarity_ratio = 1
|
||||
minimum_foreground_width_ratio = 1
|
||||
minimum_foreground_height_ratio = 1
|
||||
|
||||
for index in range(frame_count):
|
||||
if frame_count > 1:
|
||||
image.seek(index)
|
||||
rgb = image.convert("RGB")
|
||||
corners = [
|
||||
rgb.getpixel((0, 0)),
|
||||
rgb.getpixel((width - 1, 0)),
|
||||
rgb.getpixel((0, height - 1)),
|
||||
rgb.getpixel((width - 1, height - 1)),
|
||||
]
|
||||
corner_lumas = [sum(pixel) / 3 for pixel in corners]
|
||||
minimum_corner_luma = min(minimum_corner_luma, min(corner_lumas))
|
||||
maximum_corner_luma = max(maximum_corner_luma, max(corner_lumas))
|
||||
background = tuple(round(sum(pixel[channel] for pixel in corners) / len(corners)) for channel in range(3))
|
||||
foreground_mask = Image.new("L", (width, height), 0)
|
||||
foreground_pixels = foreground_mask.load()
|
||||
background_similarity = 0
|
||||
rgb_pixels = rgb.load()
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
pixel = rgb_pixels[x, y]
|
||||
diff = sum(abs(pixel[channel] - background[channel]) for channel in range(3))
|
||||
if diff > 22:
|
||||
foreground_pixels[x, y] = 255
|
||||
if diff <= 12:
|
||||
background_similarity += 1
|
||||
foreground_box = foreground_mask.getbbox()
|
||||
if foreground_box:
|
||||
minimum_foreground_width_ratio = min(
|
||||
minimum_foreground_width_ratio,
|
||||
(foreground_box[2] - foreground_box[0]) / width
|
||||
)
|
||||
minimum_foreground_height_ratio = min(
|
||||
minimum_foreground_height_ratio,
|
||||
(foreground_box[3] - foreground_box[1]) / height
|
||||
)
|
||||
minimum_background_similarity_ratio = min(
|
||||
minimum_background_similarity_ratio,
|
||||
background_similarity / (width * height)
|
||||
)
|
||||
|
||||
print(json.dumps({
|
||||
"minimumCornerLuma": minimum_corner_luma,
|
||||
"maximumCornerLuma": maximum_corner_luma,
|
||||
"minimumBackgroundSimilarityRatio": minimum_background_similarity_ratio,
|
||||
"minimumForegroundWidthRatio": minimum_foreground_width_ratio,
|
||||
"minimumForegroundHeightRatio": minimum_foreground_height_ratio,
|
||||
"width": width,
|
||||
"height": height
|
||||
}))
|
||||
`
|
||||
return JSON.parse(execFileSync('python3', ['-', assetPath], {
|
||||
encoding: 'utf8',
|
||||
input: script
|
||||
}).trim())
|
||||
}
|
||||
|
||||
const appShell = readSource('../src/views/AppShellRouteView.vue')
|
||||
const workbenchView = readSource('../src/views/PersonalWorkbenchView.vue')
|
||||
const aiMode = readSource('../src/components/business/PersonalWorkbenchAiMode.vue')
|
||||
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 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, /const workbenchMode = ref\('traditional'\)/)
|
||||
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, /<TopBar[\s\S]*:workbench-mode="workbenchMode"[\s\S]*@toggle-workbench-mode="toggleWorkbenchMode"/)
|
||||
assert.match(appShell, /<PersonalWorkbenchView[\s\S]*:workbench-mode="workbenchMode"/)
|
||||
assert.match(appShell, /const isAiShellMode = computed\(\(\) => 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, /<Transition[\s\S]*name="workbench-mode-fade"[\s\S]*mode="out-in"/)
|
||||
assert.match(workbenchView, /<PersonalWorkbenchAiMode[\s\S]*v-if="workbenchMode === 'ai'"[\s\S]*key="ai"/)
|
||||
assert.match(workbenchView, /:sidebar-command="aiSidebarCommand"/)
|
||||
assert.match(workbenchView, /@conversation-change="emit\('ai-conversation-change', \$event\)"/)
|
||||
assert.match(workbenchView, /@conversation-history-change="emit\('ai-conversation-history-change', \$event\)"/)
|
||||
assert.match(workbenchView, /<PersonalWorkbench[\s\S]*v-else[\s\S]*key="traditional"/)
|
||||
assert.match(workbenchView, /workbenchMode:\s*\{[\s\S]*type:\s*String,[\s\S]*default:\s*'traditional'/)
|
||||
assert.match(workbenchView, /aiSidebarCommand:\s*\{[\s\S]*type:\s*Object/)
|
||||
assert.match(workbenchView, /personal-workbench-view\.css/)
|
||||
assert.match(workbenchViewStyles, /\.workbench-mode-fade-enter-active,[\s\S]*\.workbench-mode-fade-leave-active\s*\{[\s\S]*transition:/)
|
||||
assert.match(workbenchViewStyles, /\.workbench-mode-fade-enter-from,[\s\S]*\.workbench-mode-fade-leave-to\s*\{[\s\S]*opacity:\s*0;[\s\S]*transform:\s*translateY\(10px\) scale\(0\.992\);/)
|
||||
assert.match(workbenchViewStyles, /@media \(prefers-reduced-motion:\s*reduce\)/)
|
||||
})
|
||||
|
||||
test('AI mode screen follows the approved reference structure', () => {
|
||||
assert.match(aiMode, /personal-workbench-ai-mode\.css/)
|
||||
assert.doesNotMatch(aiMode, /workbench-ai-mode-robot-bg\.png/)
|
||||
assert.match(aiMode, /workbench-ai-mode-orb-icon\.gif/)
|
||||
assert.match(aiMode, /<img[\s\S]*class="workbench-ai-orb__image"/)
|
||||
assert.match(aiMode, /小财管家/)
|
||||
assert.match(aiMode, /我是您的小财管家/)
|
||||
assert.match(aiMode, /placeholder="今天我能帮您做点什么?"/)
|
||||
assert.match(aiMode, /rows="3"/)
|
||||
assert.match(aiMode, /workbench-ai-composer-toolbar/)
|
||||
assert.match(aiMode, /Axiom Ultra 3\.1/)
|
||||
assert.match(aiMode, /mdi mdi-calendar-range/)
|
||||
assert.match(aiMode, /workbench-ai-date-popover/)
|
||||
assert.match(aiMode, /type="date"/)
|
||||
assert.doesNotMatch(aiMode, /mdi mdi-web/)
|
||||
assert.match(aiMode, /mdi mdi-microphone-outline/)
|
||||
assert.match(aiMode, /mdi mdi-arrow-up/)
|
||||
assert.match(aiMode, /快速开始/)
|
||||
assert.match(aiMode, /action-icon-wrapper/)
|
||||
assert.match(aiMode, /发起报销/)
|
||||
assert.match(aiMode, /查询预算/)
|
||||
assert.match(aiMode, /解释制度/)
|
||||
assert.match(aiMode, /催办审批/)
|
||||
assert.match(aiMode, /<Transition name="workbench-ai-panel-swap" mode="out-in" appear>/)
|
||||
assert.match(aiMode, /@submit\.prevent="submitAiModePrompt"/)
|
||||
assert.equal((aiMode.match(/type="submit"[\s\S]{0,160}class="workbench-ai-send-btn"/g) || []).length, 2)
|
||||
assert.match(aiMode, /class="workbench-ai-conversation"/)
|
||||
assert.match(aiMode, /class="workbench-ai-thread"[\s\S]*@scroll\.passive="handleInlineConversationScroll"/)
|
||||
assert.match(aiMode, /workbench-ai-answer-card/)
|
||||
assert.match(aiMode, /workbench-ai-answer-markdown/)
|
||||
assert.match(aiMode, /v-html="renderInlineMarkdown\(message\.content\)"/)
|
||||
assert.match(aiMode, /workbench-ai-message-actions/)
|
||||
assert.match(aiMode, /workbench-ai-conversation-actions/)
|
||||
assert.match(aiMode, /scrollInlineConversationToTop/)
|
||||
assert.match(aiMode, /requestDeleteCurrentConversation/)
|
||||
assert.match(aiMode, /confirmDeleteConversation/)
|
||||
assert.match(aiMode, /workbench-ai-confirm-dialog/)
|
||||
assert.match(aiMode, /workbench-ai-thinking-toggle/)
|
||||
assert.match(aiMode, /小财业务思考/)
|
||||
assert.match(aiMode, /class="workbench-ai-thinking-expanded"/)
|
||||
assert.match(aiMode, /class="workbench-ai-thinking-collapse-btn"/)
|
||||
assert.match(aiMode, /class="workbench-ai-thinking-collapse-btn"[\s\S]*@click="toggleInlineThinking\(message\)"/)
|
||||
assert.doesNotMatch(aiMode, /:disabled="message\.pending"/)
|
||||
assert.match(aiMode, /isInlineThinkingExpanded/)
|
||||
assert.match(aiMode, /toggleInlineThinking/)
|
||||
assert.match(aiMode, /const thinkingCollapsedMessageIds = ref\(new Set\(\)\)/)
|
||||
assert.match(aiMode, /thinkingCollapsedMessageIds\.value\.has\(message\.id\)/)
|
||||
assert.match(aiMode, /nextCollapsedIds\.add\(message\.id\)/)
|
||||
assert.match(aiMode, /nextCollapsedIds\.delete\(message\.id\)/)
|
||||
assert.match(aiMode, /message\.pending && !hasInlineThinking\(message\)/)
|
||||
assert.doesNotMatch(aiMode, /小财管家正在思考/)
|
||||
assert.doesNotMatch(aiMode, /思考过程/)
|
||||
assert.doesNotMatch(aiMode, /message\.pending \?/)
|
||||
assert.match(aiMode, /placeholder="继续和小财管家对话\.\.\."/)
|
||||
assert.match(aiMode, /import \{ fetchSettings \} from '\.\.\/\.\.\/services\/settings\.js'/)
|
||||
assert.match(aiMode, /import \{ fetchStewardPlan, fetchStewardPlanStream \} from '\.\.\/\.\.\/services\/steward\.js'/)
|
||||
assert.match(aiMode, /import \{ useWorkbenchComposerDate \} from '\.\.\/\.\.\/composables\/useWorkbenchComposerDate\.js'/)
|
||||
assert.match(aiMode, /loadAiWorkbenchConversationHistory/)
|
||||
assert.match(aiMode, /saveAiWorkbenchConversation/)
|
||||
assert.match(aiMode, /deleteAiWorkbenchConversation/)
|
||||
assert.match(aiMode, /import \{ renderMarkdown \} from '\.\.\/\.\.\/utils\/markdown\.js'/)
|
||||
assert.match(aiMode, /buildStewardPlanRequest/)
|
||||
assert.match(aiMode, /buildStewardPlanMessageText/)
|
||||
assert.match(aiMode, /buildStewardSuggestedActions/)
|
||||
assert.match(aiMode, /const emit = defineEmits\(\['conversation-change', 'conversation-history-change'\]\)/)
|
||||
assert.match(aiMode, /function startInlineConversation\(prompt, entry = \{\}, files = \[\]\)/)
|
||||
assert.match(aiMode, /activateInlineConversation\(\{[\s\S]*title:[\s\S]*\}\)[\s\S]*conversationMessages\.value\.push\(createInlineMessage\('user'/)
|
||||
assert.match(aiMode, /persistCurrentConversation\(\)/)
|
||||
assert.match(aiMode, /refreshConversationHistory\(\)/)
|
||||
assert.match(aiMode, /fetchStewardPlanStream\(/)
|
||||
assert.match(aiMode, /fetchStewardPlan\(/)
|
||||
assert.match(aiMode, /const INLINE_ANSWER_STREAM_CHUNK_SIZE = 6/)
|
||||
assert.match(aiMode, /function updateInlineMessageContent\(message, content\)/)
|
||||
assert.match(aiMode, /async function streamInlineAssistantContent\(messageId, content\)/)
|
||||
assert.match(aiMode, /const requiredApplicationContinuationFlow = resolveRequiredApplicationGateContinuationFlow\(normalizedPlan\)/)
|
||||
assert.match(aiMode, /const finalMessageText = requiredApplicationContinuationFlow[\s\S]*buildAiRequiredApplicationGateAutoMessage\(normalizedPlan, requiredApplicationContinuationFlow\)[\s\S]*buildStewardPlanMessageText\(plan\)/)
|
||||
assert.match(aiMode, /const hasServerStreamedContent = Boolean\(String\(pendingMessage\.content \|\| ''\)\.trim\(\)\)/)
|
||||
assert.match(aiMode, /if \(!hasServerStreamedContent\) \{[\s\S]*await streamInlineAssistantContent\(pendingMessage\.id, finalMessageText\)[\s\S]*\}/)
|
||||
assert.doesNotMatch(aiMode, /createInlineMessage\('assistant', buildStewardPlanMessageText\(plan\)/)
|
||||
assert.doesNotMatch(aiMode, /runOrchestrator\(/)
|
||||
assert.doesNotMatch(aiMode, /buildFallbackAnswer/)
|
||||
assert.doesNotMatch(aiMode, /已使用本地回复/)
|
||||
assert.doesNotMatch(aiMode, /emit\('open-assistant'/)
|
||||
assert.match(aiModeStyles, /--ai-theme-rgb:\s*var\(--theme-primary-rgb/)
|
||||
assert.match(aiModeStyles, /\.workbench-ai-mode\s*\{[\s\S]*min-height:\s*100%;[\s\S]*background:/)
|
||||
assert.match(aiModeStyles, /\.workbench-ai-mode\.has-conversation\s*\{[\s\S]*place-items:\s*stretch;[\s\S]*padding:\s*0;/)
|
||||
assert.match(aiModeStyles, /\.workbench-ai-composer\s*\{[\s\S]*border-radius:\s*20px;[\s\S]*box-shadow:/)
|
||||
assert.match(composerRule, /min-height:\s*154px;/)
|
||||
assert.match(composerRule, /grid-template-rows:\s*minmax\(80px,\s*1fr\) auto;/)
|
||||
assert.match(composerTextareaRule, /min-height:\s*80px;/)
|
||||
assert.doesNotMatch(aiModeStyles, /--workbench-ai-robot-image/)
|
||||
assert.match(aiBackgroundRule, /inset:\s*0;/)
|
||||
assert.match(aiBackgroundRule, /linear-gradient\(90deg,\s*rgba\(var\(--ai-theme-rgb\)/)
|
||||
assert.match(aiBackgroundRule, /background-size:\s*56px 56px,\s*56px 56px,\s*auto,\s*auto;/)
|
||||
assert.match(aiModeStyles, /\.workbench-ai-orb\s*\{[\s\S]*border-radius:\s*50%;/)
|
||||
assert.match(orbRule, /rgba\(255,\s*255,\s*255,\s*0\.98\)/)
|
||||
assert.match(orbRule, /rgba\(47,\s*124,\s*255,\s*0\.18\)/)
|
||||
assert.match(orbRule, /width:\s*clamp\(118px,\s*8vw,\s*132px\);/)
|
||||
assert.match(orbRule, /height:\s*clamp\(118px,\s*8vw,\s*132px\);/)
|
||||
assert.match(orbRule, /animation:\s*workbenchAiControlIn/)
|
||||
assert.match(orbImageRule, /width:\s*100%;/)
|
||||
assert.match(orbImageRule, /height:\s*100%;/)
|
||||
assert.match(orbImageRule, /object-fit:\s*contain;/)
|
||||
assert.match(orbImageRule, /object-position:\s*center center;/)
|
||||
assert.doesNotMatch(orbImageRule, /transform:/)
|
||||
assert.match(aiModeStyles, /@keyframes workbenchAiControlIn\s*\{[\s\S]*opacity:\s*0;[\s\S]*translateY\(18px\)[\s\S]*opacity:\s*1;/)
|
||||
assert.match(aiModeStyles, /\.workbench-ai-copy\s*\{[\s\S]*animation:\s*workbenchAiControlIn/)
|
||||
assert.match(aiModeStyles, /\.workbench-ai-composer\s*\{[\s\S]*animation:\s*workbenchAiControlIn/)
|
||||
assert.match(aiModeStyles, /\.workbench-ai-composer textarea\s*\{[\s\S]*animation:\s*workbenchAiControlIn/)
|
||||
assert.match(aiModeStyles, /\.workbench-ai-icon-btn\s*\{[\s\S]*animation:\s*workbenchAiControlIn/)
|
||||
assert.match(aiModeStyles, /\.workbench-ai-send-btn\s*\{[\s\S]*animation:\s*workbenchAiControlIn/)
|
||||
assert.match(aiModeStyles, /\.workbench-ai-action:nth-child\(4\)\s*\{[\s\S]*animation-delay:\s*520ms;/)
|
||||
assert.match(aiModeStyles, /\.workbench-ai-conversation\s*\{[\s\S]*grid-template-rows:\s*minmax\(0,\s*1fr\) auto;/)
|
||||
assert.match(aiMode, /const inlineConversationAutoScrollPinned = ref\(true\)/)
|
||||
assert.match(aiMode, /const INLINE_AUTO_SCROLL_THRESHOLD = 96/)
|
||||
assert.match(aiMode, /const INLINE_LAYOUT_SETTLE_SCROLL_DELAY_MS = 260/)
|
||||
assert.match(aiMode, /function isInlineConversationNearBottom\(\)/)
|
||||
assert.match(aiMode, /function handleInlineConversationScroll\(\)\s*\{[\s\S]*inlineConversationAutoScrollPinned\.value = isInlineConversationNearBottom\(\)[\s\S]*\}/)
|
||||
assert.match(aiMode, /function forceInlineConversationToBottom\(\)/)
|
||||
assert.match(aiMode, /el\.scrollTop = el\.scrollHeight/)
|
||||
assert.match(aiMode, /function scrollInlineConversationToBottom\(options = \{\}\)/)
|
||||
assert.match(aiMode, /const shouldScroll = options\.force !== false/)
|
||||
assert.match(aiMode, /if \(!shouldScroll\) \{[\s\S]*return[\s\S]*\}/)
|
||||
assert.match(aiMode, /window\.requestAnimationFrame\(\(\) => \{[\s\S]*forceInlineConversationToBottom\(\)[\s\S]*\}\)/)
|
||||
assert.match(aiMode, /window\.setTimeout\(\(\) => \{[\s\S]*if \(inlineConversationAutoScrollPinned\.value\) \{[\s\S]*forceInlineConversationToBottom\(\)[\s\S]*\}[\s\S]*\}, INLINE_LAYOUT_SETTLE_SCROLL_DELAY_MS\)/)
|
||||
assert.match(aiMode, /const shouldAutoScroll = inlineConversationAutoScrollPinned\.value[\s\S]*updateInlineMessageContent\(message, streamedContent\)[\s\S]*scrollInlineConversationToBottom\(\{ force: shouldAutoScroll \}\)/)
|
||||
assert.match(aiMode, /const shouldAutoScroll = inlineConversationAutoScrollPinned\.value[\s\S]*appendInlineMessageContent\(message, data\.delta \|\| data\.content \|\| data\.text \|\| ''\)[\s\S]*scrollInlineConversationToBottom\(\{ force: shouldAutoScroll \}\)/)
|
||||
assert.match(aiMode, /inlineConversationAutoScrollPinned\.value = true[\s\S]*conversationMessages\.value\.push\(createInlineMessage\('user', cleanPrompt\)\)/)
|
||||
assert.match(aiMode, /function openInlineRecentConversation\(item = \{\}\) \{[\s\S]*inlineConversationAutoScrollPinned\.value = true[\s\S]*conversationMessages\.value =/)
|
||||
assert.doesNotMatch(aiMode, /scrollTo\(\{ top: el\.scrollHeight, behavior: 'smooth' \}\)/)
|
||||
assert.match(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-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)
|
||||
})
|
||||
@@ -142,15 +142,29 @@ test('workbench model routing maps ontology result before entering assistant', (
|
||||
)
|
||||
})
|
||||
|
||||
test('workbench ambiguous travel flow uses steward fast path before ontology parsing', () => {
|
||||
const fastPathIndex = appShellComposable.indexOf(
|
||||
'fallbackSessionType === ASSISTANT_SCOPE_SESSION_STEWARD'
|
||||
)
|
||||
const ontologyParseIndex = appShellComposable.indexOf('fetchOntologyParse(')
|
||||
test('workbench smart entry blocks unsupported non-business input before ontology parsing', () => {
|
||||
const openSmartEntryStart = appShellComposable.indexOf('async function openSmartEntry(payload')
|
||||
const closeSmartEntryStart = appShellComposable.indexOf('function closeSmartEntry(')
|
||||
assert.ok(openSmartEntryStart >= 0, 'expected an openSmartEntry entry point')
|
||||
assert.ok(closeSmartEntryStart > openSmartEntryStart, 'expected closeSmartEntry to follow openSmartEntry')
|
||||
|
||||
assert.ok(fastPathIndex >= 0, 'expected steward fallback fast path in smart entry routing')
|
||||
const openSmartEntryBlock = appShellComposable.slice(openSmartEntryStart, closeSmartEntryStart)
|
||||
const guardIndex = openSmartEntryBlock.indexOf('resolveAssistantScopeGuard(')
|
||||
const blockedIndex = openSmartEntryBlock.indexOf('scopeGuard?.blocked')
|
||||
const conversationIndex = openSmartEntryBlock.indexOf('buildUnsupportedBusinessScopeConversation(prompt')
|
||||
const sessionTypeResolveIndex = openSmartEntryBlock.indexOf('resolveSmartEntrySessionType(payload)')
|
||||
|
||||
assert.ok(guardIndex >= 0, 'expected smart entry to use the business scope guard')
|
||||
assert.ok(blockedIndex >= 0, 'expected smart entry to short-circuit blocked inputs')
|
||||
assert.ok(conversationIndex >= 0, 'expected blocked smart entry inputs to seed an assistant conversation')
|
||||
assert.ok(sessionTypeResolveIndex >= 0, 'expected smart entry to delegate session resolution')
|
||||
assert.ok(
|
||||
fastPathIndex < ontologyParseIndex,
|
||||
'expected steward fallback to return before slow ontology parsing'
|
||||
blockedIndex < sessionTypeResolveIndex,
|
||||
'expected blocked inputs to stop before ontology-driven session resolution'
|
||||
)
|
||||
assert.ok(
|
||||
conversationIndex < sessionTypeResolveIndex,
|
||||
'expected unsupported input guidance to be prepared before session resolution'
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -19,33 +19,19 @@ const workbenchDateComposable = readFileSync(
|
||||
fileURLToPath(new URL('../src/composables/useWorkbenchComposerDate.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const workbenchStyles = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/components/personal-workbench.css', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const workbenchDateStyles = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/components/personal-workbench-composer-date.css', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const workbenchResponsiveStyles = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/components/personal-workbench-responsive.css', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('workbench composer renders date picker beside attachment upload', () => {
|
||||
assert.match(workbench, /aria-label="上传附件"[\s\S]*class="workbench-date-anchor"/)
|
||||
assert.match(workbench, /aria-label="选择日期"/)
|
||||
assert.match(workbench, /class="workbench-date-chip"/)
|
||||
assert.match(workbench, /removeWorkbenchDateTag/)
|
||||
assert.match(workbench, /composer-date-popover/)
|
||||
assert.match(workbench, /setWorkbenchDateMode\('single'\)/)
|
||||
assert.match(workbench, /setWorkbenchDateMode\('range'\)/)
|
||||
assert.match(workbench, /handleWorkbenchDateInputChange\('single'\)/)
|
||||
assert.match(workbench, /handleWorkbenchDateInputChange\('range-start'\)/)
|
||||
assert.match(workbench, /handleWorkbenchDateInputChange\('range-end'\)/)
|
||||
assert.doesNotMatch(workbench, /@click="applyWorkbenchDateSelection"/)
|
||||
assert.doesNotMatch(workbench, /插入标签/)
|
||||
assert.match(workbench, /useWorkbenchComposerDate/)
|
||||
test('traditional workbench no longer renders the old composer date picker', () => {
|
||||
assert.doesNotMatch(workbench, /aria-label="上传附件"[\s\S]*class="workbench-date-anchor"/)
|
||||
assert.doesNotMatch(workbench, /aria-label="选择日期"/)
|
||||
assert.doesNotMatch(workbench, /class="workbench-date-chip"/)
|
||||
assert.doesNotMatch(workbench, /removeWorkbenchDateTag/)
|
||||
assert.doesNotMatch(workbench, /composer-date-popover/)
|
||||
assert.doesNotMatch(workbench, /setWorkbenchDateMode\('single'\)/)
|
||||
assert.doesNotMatch(workbench, /useWorkbenchComposerDate/)
|
||||
assert.match(workbenchDateComposable, /const workbenchSingleDate = ref\(getTodayDateValue\(\)\)/)
|
||||
assert.match(workbenchDateComposable, /const workbenchDateTagLabel = ref\(''\)/)
|
||||
assert.match(workbenchDateComposable, /const today = getTodayDateValue\(\)[\s\S]*workbenchSingleDate\.value = today/)
|
||||
@@ -53,13 +39,6 @@ test('workbench composer renders date picker beside attachment upload', () => {
|
||||
assert.match(workbenchDateStyles, /\.workbench-date-anchor/)
|
||||
assert.match(workbenchDateStyles, /\.workbench-date-chip/)
|
||||
assert.match(workbenchDateStyles, /\.composer-date-popover/)
|
||||
assert.match(workbenchStyles, /\.assistant-composer\s*\{[\s\S]*position:\s*relative/)
|
||||
assert.match(workbenchDateStyles, /\.composer-date-popover\s*\{[\s\S]*top:\s*calc\(100% \+ 8px\)/)
|
||||
assert.doesNotMatch(workbenchDateStyles, /bottom:\s*calc\(100%/)
|
||||
assert.doesNotMatch(workbench, /composer-related-button/)
|
||||
assert.doesNotMatch(workbenchStyles, /\.composer-related-button/)
|
||||
assert.doesNotMatch(workbenchDateStyles, /\.composer-related-button/)
|
||||
assert.doesNotMatch(workbenchResponsiveStyles, /\.composer-related-button/)
|
||||
})
|
||||
|
||||
test('workbench date helper builds labels and inserts them into draft text', () => {
|
||||
|
||||
@@ -89,6 +89,52 @@ test('workbench summary builds real user notifications and progress from request
|
||||
assert.equal(summary.expenseStatsDetail.processingRows[0].stepCount, 5)
|
||||
assert.ok(summary.expenseStatsDetail.operationRows.some((item) => item.source === '待办'))
|
||||
assert.ok(summary.expenseStatsDetail.operationRows.some((item) => item.source === '进度'))
|
||||
assert.ok(Array.isArray(summary.reimbursementTrendRows))
|
||||
assert.equal(summary.reimbursementTrendRows.length, 6)
|
||||
assert.equal(summary.reimbursementTrendRows.at(-1).key, '2026-06')
|
||||
assert.equal(summary.reimbursementTrendRows.at(-1).amount, 1280)
|
||||
assert.equal(summary.reimbursementTrendRows.at(-1).previousKey, '2025-06')
|
||||
})
|
||||
|
||||
test('workbench reimbursement trend compares monthly totals with last year same period', () => {
|
||||
const summary = buildWorkbenchSummary(
|
||||
[
|
||||
{
|
||||
id: 'BX-202606',
|
||||
claimNo: 'BX-202606',
|
||||
person: currentUser.name,
|
||||
title: '六月报销',
|
||||
amount: 1280,
|
||||
createdAt: '2026-06-15T10:00:00+08:00'
|
||||
},
|
||||
{
|
||||
id: 'BX-202605',
|
||||
claimNo: 'BX-202605',
|
||||
person: currentUser.name,
|
||||
title: '五月报销',
|
||||
amount: 860,
|
||||
createdAt: '2026-05-10T10:00:00+08:00'
|
||||
},
|
||||
{
|
||||
id: 'BX-202506',
|
||||
claimNo: 'BX-202506',
|
||||
person: currentUser.name,
|
||||
title: '去年六月报销',
|
||||
amount: 920,
|
||||
createdAt: '2025-06-12T10:00:00+08:00'
|
||||
}
|
||||
],
|
||||
currentUser
|
||||
)
|
||||
|
||||
assert.deepEqual(
|
||||
summary.reimbursementTrendRows.slice(-2).map((item) => item.label),
|
||||
['5月', '6月']
|
||||
)
|
||||
assert.equal(summary.reimbursementTrendRows.at(-2).amount, 860)
|
||||
assert.equal(summary.reimbursementTrendRows.at(-2).previousAmount, 0)
|
||||
assert.equal(summary.reimbursementTrendRows.at(-1).amount, 1280)
|
||||
assert.equal(summary.reimbursementTrendRows.at(-1).previousAmount, 920)
|
||||
})
|
||||
|
||||
test('workbench progress keeps application document type for AP claims', () => {
|
||||
|
||||
Reference in New Issue
Block a user