refactor: consolidate finance workflow modules
This commit is contained in:
@@ -97,7 +97,7 @@ test('AI sidebar has quick actions, business navigation and conversation history
|
||||
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, /item\.aiIconPaths/)
|
||||
assert.match(aiSidebar, /aria-current/)
|
||||
assert.doesNotMatch(aiSidebar, /displayHint/)
|
||||
assert.doesNotMatch(aiSidebar, /个人工作台/)
|
||||
@@ -136,7 +136,7 @@ test('AI sidebar has quick actions, business navigation and conversation history
|
||||
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.match(aiSidebar, /defineEmits\(\['navigate', 'new-chat', 'open-recent', 'rename-conversation', 'logout', 'prefetch-view'\]\)/)
|
||||
assert.doesNotMatch(aiSidebar, /search-chat/)
|
||||
assert.doesNotMatch(aiSidebar, /打开系统设置/)
|
||||
assert.doesNotMatch(aiSidebar, /mdi-chevron-up/)
|
||||
@@ -154,10 +154,21 @@ test('AI sidebar visual treatment keeps a restrained three-layer workspace', ()
|
||||
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(aiSidebar, /const tablerIconPaths = \{/)
|
||||
assert.match(aiSidebar, /plus:\s*\[/)
|
||||
assert.match(aiSidebar, /search:\s*\[/)
|
||||
assert.match(aiSidebar, /fileText:\s*\[/)
|
||||
assert.match(aiSidebar, /book2:\s*\[/)
|
||||
assert.match(aiSidebar, /iconPaths:\s*tablerIconPaths\.plus/)
|
||||
assert.match(aiSidebar, /aiIconPaths:\s*sidebarMeta\[item\.id\]\?\.iconPaths/)
|
||||
assert.doesNotMatch(aiSidebar, /icon:\s*'mdi mdi-plus'/)
|
||||
assert.doesNotMatch(aiSidebar, /mdi mdi-file-document-outline/)
|
||||
assert.match(aiSidebarStyles, /\.ai-sidebar-tabler-icon\s*\{[\s\S]*stroke-width:\s*1\.85;/)
|
||||
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, /grid-template-columns:\s*32px minmax\(0,\s*1fr\);/)
|
||||
assert.match(quickButtonBlock, /gap:\s*12px;/)
|
||||
assert.match(quickButtonBlock, /padding:\s*7px 10px;/)
|
||||
assert.match(quickButtonBlock, /background:\s*transparent;/)
|
||||
assert.match(quickButtonBlock, /border-color:\s*transparent;/)
|
||||
assert.match(quickButtonBlock, /box-shadow:\s*none;/)
|
||||
@@ -170,7 +181,12 @@ test('AI sidebar visual treatment keeps a restrained three-layer workspace', ()
|
||||
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-quick-btn:hover,\s*\.ai-quick-btn\.active,\s*\.ai-nav-btn:hover,\s*\.ai-nav-btn\.active\s*\{[\s\S]*background:\s*rgba\(15,\s*23,\s*42,\s*0\.035\);/)
|
||||
assert.doesNotMatch(aiSidebarStyles, /\.ai-nav-btn::before/)
|
||||
assert.doesNotMatch(aiSidebarStyles, /\.ai-nav-btn\.active::before/)
|
||||
assert.doesNotMatch(aiSidebarStyles, /\.ai-nav-btn\.active \.ai-nav-copy/)
|
||||
assert.doesNotMatch(aiSidebarStyles, /\.ai-rail\.rail-collapsed \.ai-nav-btn\.active/)
|
||||
assert.doesNotMatch(aiSidebarStyles, /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;/)
|
||||
|
||||
@@ -8,23 +8,65 @@ const shell = readFileSync(
|
||||
'utf8'
|
||||
)
|
||||
const router = readFileSync(fileURLToPath(new URL('../src/router/index.js', import.meta.url)), 'utf8')
|
||||
const sidebarRail = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/layout/SidebarRail.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const aiSidebarRail = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/layout/AiSidebarRail.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('app shell main route views are eagerly imported', () => {
|
||||
assert.doesNotMatch(shell, /defineAsyncRouteView/)
|
||||
assert.doesNotMatch(shell, /defineAsyncComponent/)
|
||||
assert.doesNotMatch(shell, /loadingComponent:/)
|
||||
assert.doesNotMatch(shell, /\u9875\u9762\u5207\u6362\u4e2d/)
|
||||
assert.doesNotMatch(shell, /floating:\s*true/)
|
||||
assert.doesNotMatch(shell, /blocking:\s*true/)
|
||||
assert.match(shell, /import AuditView from '\.\/AuditView\.vue'/)
|
||||
assert.match(shell, /import DigitalEmployeesView from '\.\/DigitalEmployeesView\.vue'/)
|
||||
assert.match(shell, /import EmployeeManagementView from '\.\/EmployeeManagementView\.vue'/)
|
||||
assert.match(shell, /import DocumentsCenterView from '\.\/DocumentsCenterView\.vue'/)
|
||||
assert.match(shell, /import BudgetCenterView from '\.\/BudgetCenterView\.vue'/)
|
||||
test('app shell lazily loads heavy business views with an in-workarea loading state', () => {
|
||||
assert.match(shell, /defineAsyncRouteView\('audit'\)/)
|
||||
assert.match(shell, /defineAsyncRouteView\('documents'\)/)
|
||||
assert.match(shell, /defineAsyncRouteView\('workbench'\)/)
|
||||
assert.match(shell, /defineAsyncModalView\('travelCreate'\)/)
|
||||
assert.match(shell, /function prefetchAppView\(viewId\)/)
|
||||
assert.match(shell, /@prefetch-view="prefetchAppView"/)
|
||||
assert.doesNotMatch(shell, /import AuditView from '\.\/AuditView\.vue'/)
|
||||
assert.doesNotMatch(shell, /import DigitalEmployeesView from '\.\/DigitalEmployeesView\.vue'/)
|
||||
assert.doesNotMatch(shell, /import EmployeeManagementView from '\.\/EmployeeManagementView\.vue'/)
|
||||
assert.doesNotMatch(shell, /import DocumentsCenterView from '\.\/DocumentsCenterView\.vue'/)
|
||||
assert.doesNotMatch(shell, /import BudgetCenterView from '\.\/BudgetCenterView\.vue'/)
|
||||
})
|
||||
|
||||
test('top-level app routes are eagerly imported', () => {
|
||||
assert.doesNotMatch(router, /\(\)\s*=>\s*import\(/)
|
||||
test('app view preloading is triggered from both standard and AI sidebars', () => {
|
||||
assert.match(sidebarRail, /'prefetch-view'/)
|
||||
assert.match(sidebarRail, /@mouseenter="emit\('prefetch-view', item\.id\)"/)
|
||||
assert.match(sidebarRail, /@focus="emit\('prefetch-view', item\.id\)"/)
|
||||
assert.match(aiSidebarRail, /'prefetch-view'/)
|
||||
assert.match(aiSidebarRail, /@mouseenter="emit\('prefetch-view', item\.id\)"/)
|
||||
assert.match(aiSidebarRail, /@focus="emit\('prefetch-view', item\.id\)"/)
|
||||
})
|
||||
|
||||
test('async app view loader keeps transitions nonblocking and visible', () => {
|
||||
const asyncViews = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/appShellAsyncViews.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const loadingState = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/shared/AppViewLoadingState.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const modalLoadingState = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/shared/AppModalLoadingState.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
assert.match(asyncViews, /defineAsyncComponent/)
|
||||
assert.match(asyncViews, /loadingComponent:\s*options\.loadingComponent \|\| AppViewLoadingState/)
|
||||
assert.match(asyncViews, /loadingComponent:\s*AppModalLoadingState/)
|
||||
assert.match(asyncViews, /suspensible:\s*false/)
|
||||
assert.match(asyncViews, /requestIdleCallback/)
|
||||
assert.match(loadingState, /正在加载页面内容/)
|
||||
assert.match(loadingState, /app-view-loading-skeleton/)
|
||||
assert.match(modalLoadingState, /Teleport to="body"/)
|
||||
assert.match(modalLoadingState, /正在打开智能工作台/)
|
||||
})
|
||||
|
||||
test('top-level shell routes stay eager so the layout does not blank during navigation', () => {
|
||||
assert.doesNotMatch(router, /component:\s*\(\)\s*=>\s*import\(\s*'\.\.\/views\/AppShellRouteView\.vue'/)
|
||||
assert.match(router, /import AppShellRouteView from '\.\.\/views\/AppShellRouteView\.vue'/)
|
||||
assert.match(router, /import LoginRouteView from '\.\.\/views\/LoginRouteView\.vue'/)
|
||||
assert.match(router, /import SetupRouteView from '\.\.\/views\/SetupRouteView\.vue'/)
|
||||
|
||||
61
web/tests/conversation-trusted-html.test.mjs
Normal file
61
web/tests/conversation-trusted-html.test.mjs
Normal file
@@ -0,0 +1,61 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
extractTrustedHtmlBlocks,
|
||||
normalizeConversationText,
|
||||
restoreTrustedHtmlBlocks
|
||||
} from '../src/utils/conversationTrustedHtml.js'
|
||||
|
||||
test('conversation trusted html helper preserves valid document cards', () => {
|
||||
const trustedBlock = [
|
||||
'<!-- ai-trusted-html:start -->',
|
||||
'<section class="ai-document-card-list" aria-label="单据结果">',
|
||||
'<article class="ai-document-card" aria-label="单据详情">',
|
||||
'<strong>差旅申请</strong>',
|
||||
'<a class="ai-html-action-link" data-ai-action="open-document-detail" href="#ai-open-document-detail:CL-1">查看</a>',
|
||||
'</article>',
|
||||
'</section>',
|
||||
'<!-- ai-trusted-html:end -->'
|
||||
].join('')
|
||||
|
||||
const extracted = extractTrustedHtmlBlocks(`结果如下:\n\n${trustedBlock}`)
|
||||
assert.equal(extracted.trustedHtmlBlocks.length, 1)
|
||||
assert.match(extracted.content, /AI_TRUSTED_HTML_BLOCK_0/)
|
||||
|
||||
const restored = restoreTrustedHtmlBlocks(
|
||||
'<p>AI_TRUSTED_HTML_BLOCK_0</p>\n',
|
||||
extracted.trustedHtmlBlocks
|
||||
)
|
||||
assert.match(restored, /class="ai-document-card-list"/)
|
||||
assert.match(restored, /href="#ai-open-document-detail:CL-1"/)
|
||||
assert.doesNotMatch(restored, /AI_TRUSTED_HTML_BLOCK_0/)
|
||||
})
|
||||
|
||||
test('conversation trusted html helper rejects unsafe trusted blocks', () => {
|
||||
const extracted = extractTrustedHtmlBlocks([
|
||||
'<!-- ai-trusted-html:start -->',
|
||||
'<section class="ai-document-card-list">',
|
||||
'<a href="javascript:alert(1)" onclick="alert(1)">危险</a>',
|
||||
'</section>',
|
||||
'<!-- ai-trusted-html:end -->'
|
||||
].join(''))
|
||||
|
||||
assert.equal(extracted.trustedHtmlBlocks.length, 0)
|
||||
assert.equal(extracted.content.trim(), '')
|
||||
})
|
||||
|
||||
test('conversation trusted html helper normalizes business copy outside fences', () => {
|
||||
const normalized = normalizeConversationText([
|
||||
'基础信息识别结果:请核对',
|
||||
'时间:2026-02-20',
|
||||
'',
|
||||
'```',
|
||||
'金额:不要改代码块',
|
||||
'```'
|
||||
].join('\n'), { trim: true })
|
||||
|
||||
assert.match(normalized, /### 基础信息识别结果/)
|
||||
assert.match(normalized, /- \*\*时间\*\*:2026-02-20/)
|
||||
assert.match(normalized, /```\n金额:不要改代码块\n```/)
|
||||
})
|
||||
39
web/tests/document-protocol-constants.test.mjs
Normal file
39
web/tests/document-protocol-constants.test.mjs
Normal file
@@ -0,0 +1,39 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
DOCUMENT_TYPE_APPLICATION,
|
||||
DOCUMENT_TYPE_LABELS,
|
||||
DOCUMENT_TYPE_REIMBURSEMENT,
|
||||
INLINE_APPLICATION_STATUS_LABELS,
|
||||
resolveDocumentTypeLabel
|
||||
} from '../src/constants/documentProtocol.js'
|
||||
import { readSourceFile, readSourceSurface } from './helpers/sourceSurface.mjs'
|
||||
|
||||
test('document protocol constants centralize document types and labels', () => {
|
||||
assert.equal(DOCUMENT_TYPE_APPLICATION, 'application')
|
||||
assert.equal(DOCUMENT_TYPE_REIMBURSEMENT, 'reimbursement')
|
||||
assert.equal(DOCUMENT_TYPE_LABELS[DOCUMENT_TYPE_APPLICATION], '申请单')
|
||||
assert.equal(DOCUMENT_TYPE_LABELS[DOCUMENT_TYPE_REIMBURSEMENT], '报销单')
|
||||
assert.equal(resolveDocumentTypeLabel('application'), '申请单')
|
||||
assert.equal(resolveDocumentTypeLabel('expense_application'), '申请单')
|
||||
assert.equal(resolveDocumentTypeLabel('unknown', '单据'), '单据')
|
||||
})
|
||||
|
||||
test('inline application status labels live in the shared document protocol', () => {
|
||||
assert.equal(INLINE_APPLICATION_STATUS_LABELS.draft, '草稿')
|
||||
assert.equal(INLINE_APPLICATION_STATUS_LABELS.submitted, '审批中')
|
||||
assert.equal(INLINE_APPLICATION_STATUS_LABELS.pending_payment, '待付款')
|
||||
})
|
||||
|
||||
test('source surface helper loads one or more source files for source assertions', () => {
|
||||
const model = readSourceFile('constants/documentProtocol.js')
|
||||
assert.match(model, /DOCUMENT_TYPE_APPLICATION/)
|
||||
|
||||
const combined = readSourceSurface([
|
||||
'constants/documentProtocol.js',
|
||||
'composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js'
|
||||
])
|
||||
assert.match(combined, /INLINE_APPLICATION_STATUS_LABELS/)
|
||||
assert.match(combined, /normalizeInlineApplicationStatusLabel/)
|
||||
})
|
||||
@@ -1,38 +1,19 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const documentsCenterView = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/DocumentsCenterView.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const documentsCenterViewModel = readFileSync(
|
||||
fileURLToPath(new URL('../src/utils/documentCenterViewModel.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const documentsCenterLogic = `${documentsCenterView}\n${documentsCenterViewModel}`
|
||||
import { readSourceFile, readSourceSurface } from './helpers/sourceSurface.mjs'
|
||||
|
||||
const documentsCenterStyles = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/views/documents-center-view.css', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const documentListSharedStyles = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/components/document-list-shared.css', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const tableLoadingState = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/shared/TableLoadingState.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const reimbursementService = readFileSync(
|
||||
fileURLToPath(new URL('../src/services/reimbursements.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const requestsComposable = readFileSync(
|
||||
fileURLToPath(new URL('../src/composables/useRequests.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const documentsCenterView = readSourceFile('views/DocumentsCenterView.vue')
|
||||
const documentsCenterViewModel = readSourceFile('utils/documentCenterViewModel.js')
|
||||
const documentsCenterLogic = readSourceSurface([
|
||||
'views/DocumentsCenterView.vue',
|
||||
'utils/documentCenterViewModel.js'
|
||||
])
|
||||
const documentsCenterStyles = readSourceFile('assets/styles/views/documents-center-view.css')
|
||||
const documentListSharedStyles = readSourceFile('assets/styles/components/document-list-shared.css')
|
||||
const tableLoadingState = readSourceFile('components/shared/TableLoadingState.vue')
|
||||
const reimbursementService = readSourceFile('services/reimbursements.js')
|
||||
const requestsComposable = readSourceFile('composables/useRequests.js')
|
||||
|
||||
test('documents center keeps only the top scope tabs and renders risk level as a dropdown filter', () => {
|
||||
assert.match(documentsCenterView, /<nav class="status-tabs document-scope-tabs"/)
|
||||
@@ -145,7 +126,7 @@ test('documents center preserves application document type from mapped requests'
|
||||
)
|
||||
assert.match(
|
||||
documentsCenterLogic,
|
||||
/documentTypeCode === DOCUMENT_TYPE_APPLICATION \? '申请单' : '报销单'/
|
||||
/resolveDocumentTypeLabel\(documentTypeCode\)/
|
||||
)
|
||||
assert.doesNotMatch(
|
||||
documentsCenterLogic,
|
||||
|
||||
13
web/tests/helpers/sourceSurface.mjs
Normal file
13
web/tests/helpers/sourceSurface.mjs
Normal file
@@ -0,0 +1,13 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
export function readSourceFile(relativePath) {
|
||||
return readFileSync(
|
||||
fileURLToPath(new URL(`../../src/${relativePath}`, import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
}
|
||||
|
||||
export function readSourceSurface(relativePaths = []) {
|
||||
return relativePaths.map((relativePath) => readSourceFile(relativePath)).join('\n')
|
||||
}
|
||||
@@ -82,37 +82,64 @@ test('topbar bell owns document center unread notifications', () => {
|
||||
assert.match(topbar, /startDocumentInboxPolling\(\)/)
|
||||
assert.match(topbar, /stopDocumentInboxPolling\(\)/)
|
||||
assert.match(topbar, /class="notification-clear-btn"/)
|
||||
assert.match(topbar, /function clearAllNotifications\(\)/)
|
||||
assert.match(topbar, /notificationBulkActionLabel/)
|
||||
assert.match(topbar, /notificationBulkActionDisabled/)
|
||||
assert.match(topbar, /function handleNotificationBulkAction\(\)/)
|
||||
assert.match(topbar, /function markUnreadNotificationsRead\(\)/)
|
||||
assert.match(topbar, /function deleteReadNotifications\(\)/)
|
||||
assert.match(topbar, /function markNotificationRead\(item\)/)
|
||||
assert.match(topbar, /class="notification-type-icon" :class="item\.tone"/)
|
||||
assert.match(topbar, /class="notification-avatar" :class="item\.tone"/)
|
||||
assert.match(topbarStyles, /\.notification-head-copy\s*\{[\s\S]*display:\s*grid;/)
|
||||
assert.match(topbarStyles, /\.notification-head-actions\s*\{[\s\S]*display:\s*inline-flex;/)
|
||||
assert.match(topbarStyles, /\.notification-popover\s*\{[\s\S]*max-height:\s*min\(520px,\s*calc\(100vh - 96px\)\);/)
|
||||
assert.match(topbarStyles, /\.notification-list\s*\{[\s\S]*max-height:\s*min\(336px,\s*calc\(100vh - 226px\)\);[\s\S]*overflow-y:\s*auto;/)
|
||||
assert.match(topbarStyles, /\.notification-copy small\s*\{[\s\S]*-webkit-line-clamp:\s*2;/)
|
||||
assert.match(topbarStyles, /\.notification-popover\s*\{[\s\S]*max-height:\s*min\(560px,\s*calc\(100vh - 68px\)\);/)
|
||||
assert.match(topbarStyles, /\.notification-list\s*\{[\s\S]*max-height:\s*min\(420px,\s*calc\(100vh - 166px\)\);[\s\S]*overflow-y:\s*auto;/)
|
||||
assert.match(topbarStyles, /\.notification-preview\s*\{[\s\S]*-webkit-line-clamp:\s*2;/)
|
||||
assert.match(topbarStyles, /@media \(max-width: 640px\)[\s\S]*\.notification-popover\s*\{[\s\S]*position:\s*fixed;/)
|
||||
assert.match(topbarStyles, /\.notification-tabs button em\s*\{[\s\S]*border-radius:\s*4px;/)
|
||||
assert.match(topbarStyles, /\.notification-row\s*\{[\s\S]*grid-template-columns:\s*34px minmax\(0,\s*1fr\) 16px;/)
|
||||
assert.match(topbarStyles, /\.notification-row\s*\{[\s\S]*grid-template-columns:\s*52px minmax\(0,\s*1fr\);/)
|
||||
assert.doesNotMatch(topbarStyles, /\.notification-dot/)
|
||||
})
|
||||
|
||||
test('topbar notification popover uses inbox-style rows with formatted time labels', () => {
|
||||
assert.match(topbar, /class="notification-row-main"/)
|
||||
assert.match(topbar, /class="notification-row-head"/)
|
||||
test('topbar notification bulk action label follows active tab semantics', () => {
|
||||
assert.match(topbar, />\s*\{\{ notificationBulkActionLabel \}\}\s*<\/button>/)
|
||||
assert.match(topbar, /:disabled="notificationBulkActionDisabled"/)
|
||||
assert.match(topbar, /@click="handleNotificationBulkAction"/)
|
||||
assert.match(topbar, /const notificationBulkActionLabel = computed\(\(\) => \(\s*notificationTab\.value === 'unread' \? '全部已读' : '删除已读'\s*\)\)/)
|
||||
assert.match(topbar, /const notificationBulkActionDisabled = computed\(\(\) => \(\s*notificationTab\.value === 'unread'\s*\? unreadNotifications\.value\.length === 0\s*: readNotifications\.value\.length === 0\s*\)\)/)
|
||||
assert.match(topbar, /function handleNotificationBulkAction\(\) \{[\s\S]*if \(notificationTab\.value === 'unread'\) \{[\s\S]*markUnreadNotificationsRead\(\)[\s\S]*return[\s\S]*deleteReadNotifications\(\)[\s\S]*\}/)
|
||||
assert.match(topbar, /function markUnreadNotificationsRead\(\) \{[\s\S]*const currentItems = unreadNotifications\.value[\s\S]*markDocumentInboxRowsRead\(documentRows\)[\s\S]*markNotificationStatesRead\(currentItems\)/)
|
||||
assert.match(topbar, /function deleteReadNotifications\(\) \{[\s\S]*const currentItems = readNotifications\.value[\s\S]*hideNotificationStates\(currentItems\)/)
|
||||
assert.doesNotMatch(topbar, />\s*清空通知\s*<\/button>/)
|
||||
})
|
||||
|
||||
test('topbar notification popover uses reference-style avatar message rows', () => {
|
||||
assert.match(topbar, /class="notification-avatar" :class="item\.tone"/)
|
||||
assert.match(topbar, /class="notification-avatar-label"/)
|
||||
assert.match(topbar, /class="notification-avatar-badge"/)
|
||||
assert.match(topbar, /class="notification-row-content"/)
|
||||
assert.match(topbar, /class="notification-row-top"/)
|
||||
assert.match(topbar, /class="notification-row-title"/)
|
||||
assert.match(topbar, /class="notification-context"/)
|
||||
assert.match(topbar, /class="notification-row-foot"/)
|
||||
assert.match(topbar, /class="notification-category-pill"/)
|
||||
assert.match(topbar, /class="notification-preview"/)
|
||||
assert.match(topbar, /class="notification-time"/)
|
||||
assert.match(topbar, /class="notification-row-action"/)
|
||||
assert.match(topbar, /avatarLabel: resolveDocumentNotificationAvatarLabel\(row\)/)
|
||||
assert.match(topbar, /avatarLabel: resolveNotificationAvatarLabel\(item\)/)
|
||||
assert.match(topbar, /timeLabel:\s*formatNotificationTimeLabel\(row\.updatedAt \|\| row\.createdAt\)/)
|
||||
assert.match(topbar, /timeLabel:\s*formatNotificationTimeLabel\(item\.time \|\| item\.updatedAt \|\| item\.due\)/)
|
||||
assert.doesNotMatch(topbar, /class="notification-category-pill"/)
|
||||
assert.doesNotMatch(topbar, /class="notification-row-action"/)
|
||||
assert.doesNotMatch(topbar, /<time>\{\{ item\.time \}\}<\/time>/)
|
||||
assert.match(topbarStyles, /\.notification-row\s*\{[\s\S]*grid-template-columns:\s*36px minmax\(0,\s*1fr\);/)
|
||||
assert.match(topbarStyles, /\.notification-context\s*\{[\s\S]*-webkit-line-clamp:\s*2;/)
|
||||
assert.match(topbarStyles, /\.notification-row-foot\s*\{[\s\S]*justify-content:\s*space-between;/)
|
||||
assert.match(topbarStyles, /\.notification-row\s*\{[\s\S]*grid-template-columns:\s*52px minmax\(0,\s*1fr\);/)
|
||||
assert.match(topbarStyles, /\.notification-avatar\s*\{[\s\S]*border-radius:\s*999px;/)
|
||||
assert.match(topbarStyles, /\.notification-preview\s*\{[\s\S]*-webkit-line-clamp:\s*2;/)
|
||||
assert.match(topbarStyles, /\.notification-row-top\s*\{[\s\S]*justify-content:\s*space-between;/)
|
||||
assert.match(topbarStyles, /\.notification-time\s*\{[\s\S]*font-variant-numeric:\s*tabular-nums;/)
|
||||
assert.match(topbarStyles, /\.notification-row-action\s*\{[\s\S]*width:\s*28px;[\s\S]*height:\s*28px;/)
|
||||
assert.doesNotMatch(topbarStyles, /\.notification-category-pill/)
|
||||
assert.doesNotMatch(topbarStyles, /\.notification-row-action/)
|
||||
})
|
||||
|
||||
test('topbar notification popover does not render a top accent line', () => {
|
||||
assert.doesNotMatch(topbarStyles, /\.notification-popover::before/)
|
||||
assert.doesNotMatch(topbarStyles, /height:\s*2px;[\s\S]*background:\s*var\(--theme-primary-active\)/)
|
||||
})
|
||||
|
||||
test('topbar notification state is persisted through backend API with local fallback', () => {
|
||||
@@ -124,9 +151,20 @@ test('topbar notification state is persisted through backend API with local fall
|
||||
assert.match(topbarNotificationStates, /NOTIFICATION_HIDDEN_STORAGE_KEY/)
|
||||
assert.match(topbarNotificationStates, /applyRemoteStates/)
|
||||
assert.match(topbarNotificationStates, /markNotificationStateRead/)
|
||||
assert.match(topbarNotificationStates, /markNotificationStatesRead/)
|
||||
assert.match(topbarNotificationStates, /hideNotificationStates/)
|
||||
})
|
||||
|
||||
test('topbar notification bulk actions are wired to backend state API', () => {
|
||||
assert.match(topbar, /markNotificationStatesRead/)
|
||||
assert.match(topbar, /function markUnreadNotificationsRead\(\) \{[\s\S]*const currentItems = unreadNotifications\.value[\s\S]*markDocumentInboxRowsRead\(documentRows\)[\s\S]*markNotificationStatesRead\(currentItems\)/)
|
||||
assert.doesNotMatch(topbar, /function markUnreadNotificationsRead\(\) \{[\s\S]*currentItems\.forEach\(\(item\) => \{[\s\S]*markNotificationStateRead\(item\)/)
|
||||
assert.match(topbar, /function deleteReadNotifications\(\) \{[\s\S]*const currentItems = readNotifications\.value[\s\S]*hideNotificationStates\(currentItems\)/)
|
||||
assert.match(topbarNotificationStates, /function markNotificationStatesRead\(items\) \{[\s\S]*buildPatch\(item, \{ read: true, hidden: false \}\)[\s\S]*return syncNotificationPatches\(patches\)/)
|
||||
assert.match(topbarNotificationStates, /function hideNotificationStates\(items\) \{[\s\S]*buildPatch\(item, \{ read: true, hidden: true \}\)[\s\S]*return syncNotificationPatches\(patches\)/)
|
||||
assert.match(notificationStatesService, /apiRequest\('\/notification-states',\s*\{[\s\S]*method:\s*'POST'[\s\S]*body:\s*JSON\.stringify\(\{ states: batch \}\)/)
|
||||
})
|
||||
|
||||
test('document inbox reuses document center viewed-key state', () => {
|
||||
assert.match(documentInbox, /DOCUMENT_VIEWED_KEYS_CHANGE_EVENT/)
|
||||
assert.match(documentInbox, /fetchNotificationStates/)
|
||||
|
||||
@@ -40,6 +40,10 @@ const reviewPanelModelScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/travelReimbursementReviewPanelModel.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const createReviewModelScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/travelReimbursementCreateReviewModel.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const messageItemTemplate = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/travel/TravelReimbursementMessageItem.vue', import.meta.url)),
|
||||
'utf8'
|
||||
@@ -130,6 +134,13 @@ test('review drawer tools expose the default review tab before conditional docum
|
||||
)
|
||||
})
|
||||
|
||||
test('create review model remains a thin compatibility layer over review panel model', () => {
|
||||
assert.match(createReviewModelScript, /export \{[\s\S]*buildReviewFactCards[\s\S]*buildReviewRiskItems[\s\S]*\} from '\.\/travelReimbursementReviewPanelModel\.js'/)
|
||||
assert.doesNotMatch(createReviewModelScript, /function buildReviewFactCards/)
|
||||
assert.doesNotMatch(createReviewModelScript, /function buildReviewRiskItems/)
|
||||
assert.doesNotMatch(createReviewModelScript, /const REVIEW_RISK_LEVEL_META/)
|
||||
})
|
||||
|
||||
test('review drawer tool buttons switch modes instead of toggling the active mode closed', () => {
|
||||
assert.match(createViewScriptSurface, /const isReviewOverviewDrawer = computed\(\(\) => reviewDrawerMode\.value === REVIEW_DRAWER_MODE_REVIEW\)/)
|
||||
assert.match(createViewScriptSurface, /function switchReviewDrawerMode\(mode\) \{[\s\S]*if \(reviewDrawerMode\.value === mode\) \{[\s\S]*return[\s\S]*\}/)
|
||||
|
||||
52
web/tests/workbench-ai-application-gate-model.test.mjs
Normal file
52
web/tests/workbench-ai-application-gate-model.test.mjs
Normal file
@@ -0,0 +1,52 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
isOrphanInlineApplicationPreviewMessage,
|
||||
isReimbursementCreationIntent,
|
||||
resolveInlineApplicationPreviewTextAction,
|
||||
resolveLatestApplicationPreviewMessage,
|
||||
resolveLatestOrphanApplicationPreviewMessage
|
||||
} from '../src/composables/workbenchAiMode/workbenchAiApplicationGateModel.js'
|
||||
import {
|
||||
AI_APPLICATION_ACTION_SAVE_DRAFT,
|
||||
AI_APPLICATION_ACTION_SUBMIT
|
||||
} from '../src/services/aiApplicationPreviewActions.js'
|
||||
|
||||
test('workbench application gate detects reimbursement creation without catching policy questions', () => {
|
||||
assert.equal(isReimbursementCreationIntent('我要报销'), true)
|
||||
assert.equal(isReimbursementCreationIntent('帮我新建一笔报账'), true)
|
||||
assert.equal(isReimbursementCreationIntent('报销一下'), true)
|
||||
|
||||
assert.equal(isReimbursementCreationIntent('报销制度是什么'), false)
|
||||
assert.equal(isReimbursementCreationIntent('帮我查询报销进度'), false)
|
||||
assert.equal(isReimbursementCreationIntent('这张票能不能报销'), false)
|
||||
})
|
||||
|
||||
test('workbench application gate resolves save and submit text actions consistently', () => {
|
||||
assert.equal(resolveInlineApplicationPreviewTextAction('保存草稿'), AI_APPLICATION_ACTION_SAVE_DRAFT)
|
||||
assert.equal(resolveInlineApplicationPreviewTextAction(' 先保存 '), AI_APPLICATION_ACTION_SAVE_DRAFT)
|
||||
assert.equal(resolveInlineApplicationPreviewTextAction('确认提交'), AI_APPLICATION_ACTION_SUBMIT)
|
||||
assert.equal(resolveInlineApplicationPreviewTextAction('直接提交'), AI_APPLICATION_ACTION_SUBMIT)
|
||||
assert.equal(resolveInlineApplicationPreviewTextAction('继续修改'), '')
|
||||
})
|
||||
|
||||
test('workbench application gate resolves latest live or orphan preview message', () => {
|
||||
const messages = [
|
||||
{ id: 'user-1', role: 'user', content: '2月去上海出差' },
|
||||
{ id: 'assistant-orphan', role: 'assistant', content: '这是申请核对表,下方表格点击对应行即可直接编辑。' },
|
||||
{ id: 'assistant-other', role: 'assistant', content: '普通回复' }
|
||||
]
|
||||
|
||||
assert.equal(isOrphanInlineApplicationPreviewMessage(messages[1]), true)
|
||||
assert.equal(resolveLatestApplicationPreviewMessage(messages), null)
|
||||
assert.equal(resolveLatestOrphanApplicationPreviewMessage(messages)?.id, 'assistant-orphan')
|
||||
|
||||
messages.push({
|
||||
id: 'assistant-preview',
|
||||
role: 'assistant',
|
||||
content: '申请核对表',
|
||||
applicationPreview: { fields: { location: '上海' } }
|
||||
})
|
||||
assert.equal(resolveLatestApplicationPreviewMessage(messages)?.id, 'assistant-preview')
|
||||
})
|
||||
46
web/tests/workbench-ai-composer-components.test.mjs
Normal file
46
web/tests/workbench-ai-composer-components.test.mjs
Normal file
@@ -0,0 +1,46 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
function readSource(path) {
|
||||
return readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
|
||||
}
|
||||
|
||||
const aiModeComponent = readSource('../src/components/business/PersonalWorkbenchAiMode.vue')
|
||||
const aiModeTemplate = readSource('../src/components/business/PersonalWorkbenchAiMode.template.html')
|
||||
const composerComponent = readSource('../src/components/business/workbench-ai/WorkbenchAiComposer.vue')
|
||||
const fileStripComponent = readSource('../src/components/business/workbench-ai/WorkbenchAiFileStrip.vue')
|
||||
const aiModeRuntime = readSource('../src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js')
|
||||
|
||||
function countOccurrences(source, pattern) {
|
||||
return source.match(pattern)?.length || 0
|
||||
}
|
||||
|
||||
test('personal workbench AI mode reuses shared composer and file strip components', () => {
|
||||
assert.match(aiModeComponent, /import \{ proxyRefs \} from 'vue'/)
|
||||
assert.match(aiModeComponent, /import WorkbenchAiComposer from '\.\/workbench-ai\/WorkbenchAiComposer\.vue'/)
|
||||
assert.match(aiModeComponent, /import WorkbenchAiFileStrip from '\.\/workbench-ai\/WorkbenchAiFileStrip\.vue'/)
|
||||
assert.match(aiModeComponent, /const aiModeRuntime = usePersonalWorkbenchAiMode\(props, emit\)/)
|
||||
assert.match(aiModeComponent, /const workbenchAiRuntime = proxyRefs\(aiModeRuntime\)/)
|
||||
|
||||
assert.equal(countOccurrences(aiModeTemplate, /<WorkbenchAiComposer\b/g), 2)
|
||||
assert.equal(countOccurrences(aiModeTemplate, /<WorkbenchAiFileStrip\b/g), 2)
|
||||
assert.doesNotMatch(aiModeTemplate, /<form class="workbench-ai-composer"/)
|
||||
assert.doesNotMatch(aiModeTemplate, /<article v-for="file in selectedFileCards"/)
|
||||
})
|
||||
|
||||
test('shared workbench composer keeps the parent input focus ref writable', () => {
|
||||
assert.match(composerComponent, /:ref="runtime\.setAssistantInputRef"/)
|
||||
assert.match(aiModeRuntime, /function setAssistantInputRef\(element\)/)
|
||||
assert.match(aiModeRuntime, /assistantInputRef\.value = element/)
|
||||
assert.match(aiModeRuntime, /setAssistantInputRef,/)
|
||||
})
|
||||
|
||||
test('shared workbench file strip preserves OCR status badges', () => {
|
||||
assert.match(fileStripComponent, /file\.ocrState\?\.label/)
|
||||
assert.match(fileStripComponent, /class="workbench-ai-file-card__ocr"/)
|
||||
assert.match(fileStripComponent, /file\.ocrState\.status === 'recognizing'/)
|
||||
assert.match(fileStripComponent, /mdi mdi-text-recognition/)
|
||||
assert.match(fileStripComponent, /:title="file\.ocrState\.title \|\| file\.ocrState\.label"/)
|
||||
})
|
||||
@@ -156,13 +156,15 @@ const appShell = readSource('../src/views/AppShellRouteView.vue')
|
||||
const workbenchView = readSource('../src/views/PersonalWorkbenchView.vue')
|
||||
const aiMode = readSource('../src/components/business/PersonalWorkbenchAiMode.vue')
|
||||
const aiModeTemplate = readSource('../src/components/business/PersonalWorkbenchAiMode.template.html')
|
||||
const aiModeComposer = readSource('../src/components/business/workbench-ai/WorkbenchAiComposer.vue')
|
||||
const aiModeFileStrip = readSource('../src/components/business/workbench-ai/WorkbenchAiFileStrip.vue')
|
||||
const aiModeRuntimeDir = fileURLToPath(new URL('../src/composables/workbenchAiMode/', import.meta.url))
|
||||
const aiModeRuntime = readdirSync(aiModeRuntimeDir)
|
||||
.filter((file) => file.endsWith('.js'))
|
||||
.sort()
|
||||
.map((file) => readFileSync(new URL(`../src/composables/workbenchAiMode/${file}`, import.meta.url), 'utf8'))
|
||||
.join('\n')
|
||||
const aiModeSurface = `${aiMode}\n${aiModeTemplate}\n${aiModeRuntime}`
|
||||
const aiModeSurface = `${aiMode}\n${aiModeTemplate}\n${aiModeComposer}\n${aiModeFileStrip}\n${aiModeRuntime}`
|
||||
const aiModeStyles = readSource('../src/assets/styles/components/personal-workbench-ai-mode.css')
|
||||
const workbenchViewStyles = readSource('../src/assets/styles/views/personal-workbench-view.css')
|
||||
const appStyles = readSource('../src/assets/styles/app.css')
|
||||
@@ -228,7 +230,7 @@ test('AI mode screen follows the approved reference structure', () => {
|
||||
assert.match(aiModeSurface, /费用测算中,请稍等/)
|
||||
assert.match(aiModeSurface, /rows="3"/)
|
||||
assert.match(aiModeSurface, /workbench-ai-composer-toolbar/)
|
||||
assert.match(aiModeSurface, /<article v-for="file in selectedFileCards"[\s\S]*class="workbench-ai-file-card"/)
|
||||
assert.match(aiModeSurface, /<article v-for="file in runtime\.selectedFileCards"[\s\S]*class="workbench-ai-file-card"/)
|
||||
assert.match(aiModeSurface, /class="workbench-ai-file-card__ocr"/)
|
||||
assert.match(aiModeSurface, /file\.ocrState\?\.label/)
|
||||
assert.match(aiModeSurface, /mdi mdi-text-recognition/)
|
||||
@@ -301,8 +303,9 @@ test('AI mode screen follows the approved reference structure', () => {
|
||||
assert.match(aiModeSurface, /解释制度/)
|
||||
assert.match(aiModeSurface, /催办审批/)
|
||||
assert.match(aiModeSurface, /<Transition name="workbench-ai-panel-swap" mode="out-in" appear>/)
|
||||
assert.match(aiModeSurface, /@submit\.prevent="submitAiModePrompt"/)
|
||||
assert.equal((aiModeSurface.match(/type="submit"[\s\S]{0,160}class="workbench-ai-send-btn"/g) || []).length, 2)
|
||||
assert.match(aiModeSurface, /@submit\.prevent="runtime\.submitAiModePrompt"/)
|
||||
assert.equal((aiModeSurface.match(/<WorkbenchAiComposer\b/g) || []).length, 2)
|
||||
assert.equal((aiModeSurface.match(/type="submit"[\s\S]{0,160}class="workbench-ai-send-btn"/g) || []).length, 1)
|
||||
assert.match(aiModeSurface, /class="workbench-ai-conversation"/)
|
||||
assert.match(aiModeSurface, /class="workbench-ai-thread"[\s\S]*@scroll\.passive="handleInlineConversationScroll"/)
|
||||
assert.match(aiModeSurface, /workbench-ai-answer-card/)
|
||||
@@ -393,6 +396,14 @@ test('AI mode screen follows the approved reference structure', () => {
|
||||
aiModeSurface,
|
||||
/suggestedActions:\s*buildInlineApplicationPreviewSuggestedActions\([\s\S]*targetMessage\.applicationPreview/
|
||||
)
|
||||
assert.match(aiModeSurface, /function isOrphanInlineApplicationPreviewMessage\(message = \{\}\)/)
|
||||
assert.match(aiModeSurface, /function resolveLatestOrphanApplicationPreviewMessage\(messages = \[\]\)/)
|
||||
assert.match(aiModeSurface, /function resolveLatestOrphanInlineApplicationPreviewMessage\(\)/)
|
||||
assert.match(aiModeSurface, /当前申请核对表状态不完整,我先重新生成可编辑表格。/)
|
||||
assert.match(
|
||||
aiModeSurface,
|
||||
/const previewSourceText = resolveLatestInlineUserPrompt\(\)[\s\S]*pushInlineApplicationActionUserMessage\(prompt\)[\s\S]*startAiApplicationPreview\('travel', '差旅费', previewSourceText/
|
||||
)
|
||||
assert.doesNotMatch(aiModeSurface, /\*\*申请单号:\*\*/)
|
||||
assert.doesNotMatch(aiModeSurface, /createInlineMessage\('assistant', buildStewardPlanMessageText\(plan\)/)
|
||||
assert.doesNotMatch(aiModeSurface, /runOrchestrator\(/)
|
||||
|
||||
@@ -314,7 +314,10 @@ test('linked application selection can create reimbursement draft from associati
|
||||
})
|
||||
|
||||
test('personal workbench routes reimbursement creation intent to association gate before steward', () => {
|
||||
assert.match(personalWorkbenchAiMode, /function isReimbursementCreationIntent\(prompt = ''\)/)
|
||||
assert.match(
|
||||
personalWorkbenchAiMode,
|
||||
/import \{ isReimbursementCreationIntent \} from '\.\/workbenchAiApplicationGateModel\.js'/
|
||||
)
|
||||
const startConversationIndex = personalWorkbenchAiMode.indexOf('function startInlineConversation')
|
||||
const gateIndex = personalWorkbenchAiMode.indexOf('expenseFlow.startAiReimbursementAssociationGate(cleanPrompt', startConversationIndex)
|
||||
const stewardIndex = personalWorkbenchAiMode.indexOf('stewardFlow.requestInlineAssistantReply(cleanPrompt', startConversationIndex)
|
||||
|
||||
Reference in New Issue
Block a user