feat(platform): close AI expense value loop
Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
This commit is contained in:
204
web/tests/agent-release-monitor-panel.test.mjs
Normal file
204
web/tests/agent-release-monitor-panel.test.mjs
Normal file
@@ -0,0 +1,204 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import {
|
||||
labelAgentAssetReleaseObservation,
|
||||
normalizeAgentAssetReleaseReviewLabel,
|
||||
normalizeAgentAssetReleaseReviewQueue
|
||||
} from '../src/services/agentAssets.js'
|
||||
import {
|
||||
buildReleaseMonitorMetricCards,
|
||||
buildReleaseReviewDocumentRoute
|
||||
} from '../src/views/scripts/useAuditReleaseMonitor.js'
|
||||
|
||||
function source(path) {
|
||||
return readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
|
||||
}
|
||||
|
||||
const panel = source('../src/components/audit/AuditReleaseMonitorPanel.vue')
|
||||
const detail = source('../src/components/audit/AuditJsonRiskRuleDetail.vue')
|
||||
const view = source('../src/views/AuditView.vue')
|
||||
const service = source('../src/services/agentAssets.js')
|
||||
const composable = source('../src/views/scripts/useAuditReleaseMonitor.js')
|
||||
|
||||
test('盲审面板正负样本混排且不读取或暗示模型预测', () => {
|
||||
assert.match(panel, /正负样本随机混排/u)
|
||||
assert.match(panel, /候选与基线结论对复核人隐藏/u)
|
||||
assert.match(panel, /模型结论已隐藏/u)
|
||||
assert.match(panel, /存在真实风险/u)
|
||||
assert.match(panel, /确认无该风险/u)
|
||||
assert.doesNotMatch(panel, /candidate_hit|baseline_hit/u)
|
||||
assert.doesNotMatch(service, /candidate_hit|baseline_hit/u)
|
||||
assert.doesNotMatch(panel, /'confirmed'|'false_positive'/u)
|
||||
assert.match(panel, /'risk_present'/u)
|
||||
assert.match(panel, /'risk_absent'/u)
|
||||
assert.doesNotMatch(panel, /确认命中|标记误报|待复核正例/u)
|
||||
assert.match(panel, /class="review-decision"[\s\S]*class="review-decision"/u)
|
||||
assert.match(panel, /:key="item\.sample_id \|\| item\.observation_id"/u)
|
||||
assert.match(panel, /target="_blank"/u)
|
||||
assert.match(panel, /rel="noopener noreferrer"/u)
|
||||
})
|
||||
|
||||
test('复核队列只保留盲审字段并可从 source_document_id 打开单据', () => {
|
||||
const queue = normalizeAgentAssetReleaseReviewQueue({
|
||||
asset_id: 'asset-1',
|
||||
release_id: 'release-1',
|
||||
stage: 'shadow',
|
||||
version: 'v2',
|
||||
pending_total: 1,
|
||||
telemetry_status: 'collecting',
|
||||
items: [{
|
||||
sample_id: 'sample-1',
|
||||
observation_id: 'observation-1',
|
||||
source_document_id: 'claim-1',
|
||||
rule_code: 'RISK-001',
|
||||
business_stage: 'reimbursement',
|
||||
prediction_blinded: true,
|
||||
reviewer_count: 1,
|
||||
required_reviewers: 2,
|
||||
conflicted: false,
|
||||
created_at: '2026-07-17T00:00:00Z',
|
||||
candidate_hit: true,
|
||||
baseline_hit: false
|
||||
}]
|
||||
})
|
||||
|
||||
assert.deepEqual(Object.keys(queue.items[0]).sort(), [
|
||||
'business_stage', 'conflicted', 'created_at', 'observation_id', 'prediction_blinded',
|
||||
'required_reviewers', 'reviewer_count', 'rule_code', 'sample_id', 'source_document_id'
|
||||
])
|
||||
assert.equal(queue.items[0].prediction_blinded, true)
|
||||
assert.deepEqual(buildReleaseReviewDocumentRoute(queue.items[0]), {
|
||||
name: 'app-document-detail',
|
||||
params: { requestId: 'claim-1' }
|
||||
})
|
||||
assert.equal(buildReleaseReviewDocumentRoute({ source_document_id: '' }), null)
|
||||
})
|
||||
|
||||
test('负样本证据未就绪时召回和实际漏检不可用,null 不伪装为 0', () => {
|
||||
const queue = normalizeAgentAssetReleaseReviewQueue({
|
||||
pending_total: 4,
|
||||
metrics: {
|
||||
observed_count: 20,
|
||||
runtime_failure_count: 1,
|
||||
runtime_failure_rate: 0.05,
|
||||
negative_sample_count: 8,
|
||||
negative_labeled_count: 3,
|
||||
negative_pending_label_count: 5,
|
||||
false_negative_count: null,
|
||||
estimated_false_negative_count: null,
|
||||
false_negative_upper_bound: null,
|
||||
random_negative_population_count: 12,
|
||||
random_negative_sample_count: 4,
|
||||
random_negative_labeled_count: 1,
|
||||
recall: null,
|
||||
recall_lower_bound: null,
|
||||
recall_confidence_level: 0.95,
|
||||
recall_method: 'stratified_random_audit_wilson_upper_bound',
|
||||
negative_ground_truth_status: 'insufficient_random_negative_reviews'
|
||||
}
|
||||
})
|
||||
const cards = cardsByLabel(buildReleaseMonitorMetricCards(queue))
|
||||
|
||||
assert.equal(queue.metrics.false_negative_count, null)
|
||||
assert.equal(cards['实际漏检'].value, '不可用')
|
||||
assert.match(cards['实际漏检'].hint, /不按 0 展示/u)
|
||||
assert.equal(cards['估计漏检'].value, '不可用')
|
||||
assert.equal(cards['召回率'].value, '不可用')
|
||||
assert.equal(cards['召回率'].hint, '随机负样本复核不足')
|
||||
assert.equal(cards['负样本积压'].value, '5')
|
||||
assert.equal(cards['负样本标注进度'].value, '37.5%')
|
||||
assert.equal(cards['随机负样本抽检'].value, '25.0%')
|
||||
})
|
||||
|
||||
test('真实负样本证据显示召回点估计、保守下界和漏检上界', () => {
|
||||
const cards = cardsByLabel(buildReleaseMonitorMetricCards({
|
||||
pending_total: 2,
|
||||
metrics: {
|
||||
observed_count: 100,
|
||||
runtime_failure_count: 0,
|
||||
runtime_failure_rate: 0,
|
||||
negative_sample_count: 20,
|
||||
negative_labeled_count: 18,
|
||||
negative_pending_label_count: 2,
|
||||
false_negative_count: 2,
|
||||
estimated_false_negative_count: 2.5,
|
||||
false_negative_upper_bound: 4.75,
|
||||
random_negative_population_count: 40,
|
||||
random_negative_sample_count: 10,
|
||||
random_negative_labeled_count: 10,
|
||||
recall: 0.8,
|
||||
recall_lower_bound: 0.64,
|
||||
recall_confidence_level: 0.95,
|
||||
recall_method: 'stratified_random_audit_wilson_upper_bound',
|
||||
negative_ground_truth_status: 'available_stratified_random_audit'
|
||||
}
|
||||
}))
|
||||
|
||||
assert.equal(cards['实际漏检'].value, '2')
|
||||
assert.equal(cards['估计漏检'].value, '2.5')
|
||||
assert.equal(cards['估计漏检'].hint, '保守上界 4.75')
|
||||
assert.equal(cards['负样本标注进度'].value, '90.0%')
|
||||
assert.equal(cards['负样本积压'].value, '2')
|
||||
assert.equal(cards['召回率'].value, '80.0%')
|
||||
assert.match(cards['召回率'].hint, /保守下界 64\.0% · 95% 置信度/u)
|
||||
assert.match(cards['召回率'].hint, /分层随机盲审 Wilson 保守上界/u)
|
||||
})
|
||||
|
||||
test('发布复核使用独立租户鉴权 API、幂等请求号和中性结果文案', () => {
|
||||
assert.match(service, /\/release\/review-queue/u)
|
||||
assert.match(service, /\/release\/review-queue\/\$\{observationId\}\/labels/u)
|
||||
assert.match(service, /normalizeAgentAssetReleaseReviewQueue\(payload\)/u)
|
||||
assert.match(composable, /requestId:\s*requestId\(assetId, observationId, label\)/u)
|
||||
assert.match(composable, /Promise\.all\(\[/u)
|
||||
assert.match(composable, /fetchAgentAssetReleaseState/u)
|
||||
assert.match(composable, /fetchAgentAssetReleaseReviewQueue/u)
|
||||
assert.match(composable, /已提交“存在真实风险”/u)
|
||||
assert.match(composable, /已提交“确认无该风险”/u)
|
||||
assert.equal(normalizeAgentAssetReleaseReviewLabel('risk_present'), 'risk_present')
|
||||
assert.equal(normalizeAgentAssetReleaseReviewLabel('risk_absent'), 'risk_absent')
|
||||
assert.throws(() => normalizeAgentAssetReleaseReviewLabel('confirmed'), /risk_present/u)
|
||||
assert.throws(() => normalizeAgentAssetReleaseReviewLabel('false_positive'), /risk_absent/u)
|
||||
})
|
||||
|
||||
test('复核服务只发送 risk_present 或 risk_absent 新语义', async () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
const bodies = []
|
||||
globalThis.fetch = async (_url, options) => {
|
||||
bodies.push(JSON.parse(options.body))
|
||||
return new Response(JSON.stringify({ label: options.body.includes('risk_present') ? 'risk_present' : 'risk_absent' }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
await labelAgentAssetReleaseObservation('asset-1', 'observation-1', 'risk_present', {
|
||||
actor: 'auditor', requestId: 'blind-review-present'
|
||||
})
|
||||
await labelAgentAssetReleaseObservation('asset-1', 'observation-2', 'risk_absent', {
|
||||
actor: 'auditor', requestId: 'blind-review-absent'
|
||||
})
|
||||
assert.throws(
|
||||
() => labelAgentAssetReleaseObservation('asset-1', 'observation-3', 'confirmed'),
|
||||
/risk_present/u
|
||||
)
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
|
||||
assert.deepEqual(bodies, [{ label: 'risk_present' }, { label: 'risk_absent' }])
|
||||
})
|
||||
|
||||
test('规则详情和审计页复用独立发布监控组件', () => {
|
||||
assert.match(detail, /<AuditReleaseMonitorPanel/u)
|
||||
assert.match(detail, /@label="\(observationId, label\) => emit\('label-release'/u)
|
||||
assert.match(view, /:release-queue="releaseQueue"/u)
|
||||
assert.match(view, /@label-release="submitReleaseLabel"/u)
|
||||
})
|
||||
|
||||
function cardsByLabel(cards) {
|
||||
return Object.fromEntries(cards.map((card) => [card.label, card]))
|
||||
}
|
||||
@@ -116,12 +116,16 @@ test('documents center uses the full request list instead of the global date-fil
|
||||
})
|
||||
|
||||
test('workbench summary merges approval inbox requests without polluting document center rows', () => {
|
||||
assert.match(appShellComposable, /import \{ fetchAllApprovalExpenseClaims, fetchExpenseClaimDetail \} from '\.\.\/services\/reimbursements\.js'/)
|
||||
assert.match(
|
||||
appShellComposable,
|
||||
/import \{\s*REIMBURSEMENT_LIST_PREVIEW_PARAMS,\s*extractExpenseClaimItems,\s*fetchApprovalExpenseClaims,\s*fetchExpenseClaimDetail\s*\} from '\.\.\/services\/reimbursements\.js'/
|
||||
)
|
||||
assert.match(appShellComposable, /const workbenchApprovalRequests = ref\(\[\]\)/)
|
||||
assert.match(appShellComposable, /async function reloadWorkbenchApprovalRequests\(\)/)
|
||||
assert.match(appShellComposable, /async function reloadWorkbenchRequests\(\)/)
|
||||
assert.match(appShellComposable, /fetchAllApprovalExpenseClaims\(\)/)
|
||||
assert.match(appShellComposable, /payload\.map\(\(item\) => mapExpenseClaimToRequest\(item\)\)/)
|
||||
assert.match(appShellComposable, /fetchApprovalExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
|
||||
assert.match(appShellComposable, /extractExpenseClaimItems\(payload\)\.map\(\(item\) => mapExpenseClaimToRequest\(item\)\)/)
|
||||
assert.doesNotMatch(appShellComposable, /fetchAllApprovalExpenseClaims\(\)/)
|
||||
assert.match(appShellComposable, /Promise\.all\(\[[\s\S]*reloadRequests\(\{ silent: true \}\),[\s\S]*reloadWorkbenchApprovalRequests\(\)[\s\S]*\]\)/)
|
||||
assert.match(appShellComposable, /if \(view === 'workbench'\) \{[\s\S]*void reloadWorkbenchRequests\(\)/)
|
||||
assert.match(appShellComposable, /const workbenchRequests = computed\(\(\) =>[\s\S]*mergeWorkbenchRequests\(requests\.value, workbenchApprovalRequests\.value\)/)
|
||||
@@ -161,7 +165,10 @@ test('document detail navigation preserves document center list query', () => {
|
||||
})
|
||||
|
||||
test('document detail refreshes claim detail instead of relying on stale list cache', () => {
|
||||
assert.match(appShellComposable, /import \{ fetchAllApprovalExpenseClaims, fetchExpenseClaimDetail \} from '\.\.\/services\/reimbursements\.js'/)
|
||||
assert.match(
|
||||
appShellComposable,
|
||||
/import \{\s*REIMBURSEMENT_LIST_PREVIEW_PARAMS,\s*extractExpenseClaimItems,\s*fetchApprovalExpenseClaims,\s*fetchExpenseClaimDetail\s*\} from '\.\.\/services\/reimbursements\.js'/
|
||||
)
|
||||
assert.match(appShellComposable, /import \{ mapExpenseClaimToRequest, useRequests \} from '\.\/useRequests\.js'/)
|
||||
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\)/)
|
||||
|
||||
@@ -71,10 +71,10 @@ test('claim delete flow invalidates the matching financial assistant session', (
|
||||
fileURLToPath(new URL('../src/views/AppShellRouteView.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const createViewScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const createViewScript = [
|
||||
'../src/views/scripts/TravelReimbursementCreateView.js',
|
||||
'../src/views/scripts/useTravelReimbursementCreateViewSessionCleanup.js'
|
||||
].map((path) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')).join('\n')
|
||||
|
||||
assert.match(appShellScript, /clearAssistantSessionSnapshotForDraftClaim/)
|
||||
assert.match(appShellScript, /async function handleRequestDeleted\(payload = \{\}\)/)
|
||||
@@ -194,8 +194,12 @@ test('saving a draft keeps the financial assistant open for continued work', ()
|
||||
})
|
||||
|
||||
test('detail smart entry is scoped to the current claim instead of the latest conversation', () => {
|
||||
const detailViewScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/TravelRequestDetailView.js', import.meta.url)),
|
||||
const detailExpenseEditorScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelRequestDetailExpenseEditor.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const smartEntryRecognitionScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/travelRequestDetailSmartEntryRecognition.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const appShellScript = readFileSync(
|
||||
@@ -206,13 +210,17 @@ test('detail smart entry is scoped to the current claim instead of the latest co
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSessionState.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const submitComposerScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const submitComposerScript = [
|
||||
'../src/views/scripts/useTravelReimbursementSubmitComposer.js',
|
||||
'../src/views/scripts/travelReimbursementSubmitApplicationPreview.js'
|
||||
].map((path) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')).join('\n')
|
||||
|
||||
assert.match(detailViewScript, /restoreLatestConversation:\s*false/)
|
||||
assert.match(detailViewScript, /scope:\s*claimId[\s\S]*type:\s*'claim'[\s\S]*claimId/)
|
||||
assert.match(detailExpenseEditorScript, /if \(!request\.value\.claimId\) \{[\s\S]*当前草稿缺少 claimId/)
|
||||
assert.match(detailExpenseEditorScript, /startSmartEntryRecognitionTask\(\{[\s\S]*claimId:\s*request\.value\.claimId/)
|
||||
assert.match(detailExpenseEditorScript, /bindSmartEntryRecognitionTask\(request\.value\.claimId\)/)
|
||||
assert.match(smartEntryRecognitionScript, /const normalizedClaimId = normalizeSmartEntryClaimId\(claimId\)/)
|
||||
assert.match(smartEntryRecognitionScript, /smartEntryRecognitionTasks\.set\(normalizedClaimId, task\)/)
|
||||
assert.doesNotMatch(detailExpenseEditorScript, /restoreLatestConversation/)
|
||||
assert.match(appShellScript, /function isDetailClaimScopedPayload\(payload = \{\}\)/)
|
||||
assert.match(appShellScript, /if \(isDetailClaimScopedPayload\(payload\)\) \{[\s\S]*return null[\s\S]*\}/)
|
||||
assert.match(sessionStateScript, /const shouldPersistLocalSnapshot = props\.entrySource !== 'detail'/)
|
||||
|
||||
426
web/tests/cfo-value-dashboard.test.mjs
Normal file
426
web/tests/cfo-value-dashboard.test.mjs
Normal file
@@ -0,0 +1,426 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import {
|
||||
buildCfoValueSearch,
|
||||
buildSavingsOpportunitySearch,
|
||||
normalizeAnalyticsValuePayload,
|
||||
recordSavingsRealization
|
||||
} from '../src/services/analyticsValue.js'
|
||||
import {
|
||||
buildBreakdownGroups,
|
||||
buildDataQualityRows,
|
||||
buildValueKpis,
|
||||
classifyCfoDashboardState,
|
||||
formatMoneyValues,
|
||||
isDashboardStale,
|
||||
readValueFiltersFromQuery,
|
||||
resolveRangeWindow,
|
||||
writeValueFiltersToQuery
|
||||
} from '../src/views/scripts/cfoValueDashboardModel.js'
|
||||
import {
|
||||
BUDGET_CONFIGURATION_NOTICE,
|
||||
buildCfoOpportunitySourceLinks,
|
||||
hasValueOpportunityQuery,
|
||||
normalizeValueOpportunityId,
|
||||
opportunityMatchesValueContext,
|
||||
readBudgetConfigurationFocus,
|
||||
readValueOpportunityId,
|
||||
shouldClearValueOpportunityError,
|
||||
writeValueOpportunityToQuery
|
||||
} from '../src/views/scripts/cfoValueSourceLinks.js'
|
||||
|
||||
const overviewView = readSource('../src/views/OverviewView.vue')
|
||||
const topBarRange = readSource('../src/components/layout/useTopBarOverviewRange.js')
|
||||
const appShell = readSource('../src/views/AppShellRouteView.vue')
|
||||
const appShellComposable = readSource('../src/composables/useAppShell.js')
|
||||
const budgetCenter = readSource('../src/views/BudgetCenterView.vue')
|
||||
const budgetCenterScript = readSource('../src/views/scripts/BudgetCenterView.js')
|
||||
const dashboardComponent = readSource('../src/components/dashboard/CfoValueDashboard.vue')
|
||||
const dashboardComposable = readSource('../src/composables/useCfoValueDashboard.js')
|
||||
const dashboardModel = readSource('../src/views/scripts/cfoValueDashboardModel.js')
|
||||
const valueService = readSource('../src/services/analyticsValue.js')
|
||||
const actionDialog = readSource('../src/components/dashboard/CfoValueActionDialog.vue')
|
||||
const opportunityDrawer = readSource('../src/components/dashboard/CfoValueOpportunityDrawer.vue')
|
||||
const trendChart = readSource('../src/components/charts/CfoValueTrendChart.vue')
|
||||
const dashboardStyles = readSource('../src/assets/styles/components/cfo-value-dashboard.css')
|
||||
|
||||
test('经营价值接口把后端 snake_case 契约规范为前端字段且保留币种分账', () => {
|
||||
const normalized = normalizeAnalyticsValuePayload({
|
||||
source: { data_status: 'complete', opportunity_count: 2 },
|
||||
funnel: { realization_rate_by_currency: { CNY: '0.2500', USD: null } },
|
||||
data_quality: { pending_confirmation_count: 1 }
|
||||
})
|
||||
|
||||
assert.equal(normalized.source.dataStatus, 'complete')
|
||||
assert.equal(normalized.source.opportunityCount, 2)
|
||||
assert.equal(normalized.funnel.realizationRateByCurrency.CNY, '0.2500')
|
||||
assert.equal(normalized.funnel.realizationRateByCurrency.USD, null)
|
||||
assert.equal(normalized.dataQuality.pendingConfirmationCount, 1)
|
||||
})
|
||||
|
||||
test('经营价值与机会台账查询只发送真实筛选字段', () => {
|
||||
const dashboardSearch = buildCfoValueSearch({
|
||||
start: '2026-07-01T00:00:00.000Z',
|
||||
end: '2026-07-16T00:00:00.000Z',
|
||||
departmentId: 'dept-1',
|
||||
projectCode: '',
|
||||
valueKind: 'cash'
|
||||
})
|
||||
const opportunitySearch = buildSavingsOpportunitySearch({
|
||||
page: 2,
|
||||
pageSize: 12,
|
||||
status: 'in_progress',
|
||||
ownerId: 'finance-1'
|
||||
})
|
||||
|
||||
assert.equal(dashboardSearch.get('department_id'), 'dept-1')
|
||||
assert.equal(dashboardSearch.get('value_kind'), 'cash')
|
||||
assert.equal(dashboardSearch.has('project_code'), false)
|
||||
assert.equal(opportunitySearch.get('page_size'), '12')
|
||||
assert.equal(opportunitySearch.get('status'), 'in_progress')
|
||||
assert.equal(opportunitySearch.get('owner_id'), 'finance-1')
|
||||
})
|
||||
|
||||
test('看板明确区分 loading permission error empty partial stale 与 ready', () => {
|
||||
const complete = dashboardFixture({ dataStatus: 'complete' })
|
||||
const partial = dashboardFixture({ dataStatus: 'partial' })
|
||||
const empty = dashboardFixture({ dataStatus: 'empty', opportunityCount: 0, realizationCount: 0 })
|
||||
const stale = dashboardFixture({ dataStatus: 'complete', freshnessAt: '2026-07-01T00:00:00Z' })
|
||||
|
||||
assert.equal(classifyCfoDashboardState({ loading: true }), 'loading')
|
||||
assert.equal(classifyCfoDashboardState({ error: { status: 403 } }), 'permission')
|
||||
assert.equal(classifyCfoDashboardState({ error: new Error('offline') }), 'error')
|
||||
assert.equal(classifyCfoDashboardState({ dashboard: empty }), 'empty')
|
||||
assert.equal(classifyCfoDashboardState({ dashboard: partial }), 'partial')
|
||||
assert.equal(classifyCfoDashboardState({ dashboard: dashboardFixture({ dataStatus: 'complete', freshnessAt: '' }) }), 'partial')
|
||||
assert.equal(classifyCfoDashboardState({ dashboard: complete, now: new Date('2026-07-16T01:00:00Z') }), 'ready')
|
||||
assert.equal(classifyCfoDashboardState({ dashboard: stale, now: new Date('2026-07-16T01:00:00Z') }), 'stale')
|
||||
assert.equal(isDashboardStale(stale, new Date('2026-07-16T01:00:00Z')), true)
|
||||
})
|
||||
|
||||
test('数据质量缺字段显示不可用而不是伪装成零缺口', () => {
|
||||
const rows = buildDataQualityRows({ dataQuality: { pendingConfirmationCount: 0 } })
|
||||
assert.deepEqual(rows[0], {
|
||||
key: 'pendingConfirmationCount',
|
||||
label: '待财务确认',
|
||||
count: 0,
|
||||
displayValue: '0',
|
||||
tone: 'ok'
|
||||
})
|
||||
assert.equal(rows[1].count, null)
|
||||
assert.equal(rows[1].displayValue, '—')
|
||||
assert.equal(rows[1].tone, 'unavailable')
|
||||
})
|
||||
|
||||
test('零节省、空台账与缺失工时基线不会混为默认数字', () => {
|
||||
const dashboard = dashboardFixture({ dataStatus: 'complete', opportunityCount: 2, realizationCount: 1 })
|
||||
dashboard.kpis = {
|
||||
verifiedCash: { label: '财务确认净现金节省', status: 'empty', values: [], confirmedRealizationCount: 0 },
|
||||
releasableLabor: { label: '财务确认可释放工时价值', status: 'collecting', reason: '缺少工时基线', requiredInputs: ['人工分钟'] },
|
||||
safeStraightThrough: { label: '安全智能直通率', status: 'collecting', reason: '缺少审计样本', requiredInputs: ['审计结果'] }
|
||||
}
|
||||
dashboard.funnel = { stages: [{ key: 'estimated', values: [{ currency: 'USD', amount: '50.00' }] }] }
|
||||
|
||||
const [cash, labor, straightThrough] = buildValueKpis(dashboard)
|
||||
assert.equal(cash.state, 'zero')
|
||||
assert.match(cash.displayValue, /US\$0|\$0/u)
|
||||
assert.equal(labor.state, 'baseline-missing')
|
||||
assert.equal(labor.displayValue, '待采集')
|
||||
assert.equal(straightThrough.displayValue, '待采集')
|
||||
assert.match(
|
||||
formatMoneyValues([{ currency: 'CNY', amount: '10' }, { currency: 'USD', amount: '20' }]),
|
||||
/¥10 \/ (?:US)?\$20/u
|
||||
)
|
||||
|
||||
dashboard.filters = { valueKind: 'labor' }
|
||||
const [excludedCash] = buildValueKpis(dashboard)
|
||||
assert.equal(excludedCash.state, 'unavailable')
|
||||
assert.equal(excludedCash.displayValue, '未纳入筛选')
|
||||
})
|
||||
|
||||
test('价值分解按币种拆行,绝不把不同币种相加后比较', () => {
|
||||
const groups = buildBreakdownGroups({
|
||||
breakdowns: [{
|
||||
dimension: 'department',
|
||||
items: [{
|
||||
dimensionId: 'dept-1',
|
||||
dimensionName: '财务部',
|
||||
opportunityCount: 2,
|
||||
verifiedValues: [
|
||||
{ currency: 'CNY', amount: '100' },
|
||||
{ currency: 'USD', amount: '20' }
|
||||
],
|
||||
estimatedValues: [
|
||||
{ currency: 'CNY', amount: '180' },
|
||||
{ currency: 'USD', amount: '30' }
|
||||
]
|
||||
}]
|
||||
}]
|
||||
})
|
||||
|
||||
assert.equal(groups[0].items.length, 2)
|
||||
assert.deepEqual(groups[0].items.map((item) => item.currency), ['CNY', 'USD'])
|
||||
assert.deepEqual(groups[0].items.map((item) => item.verifiedMagnitude), [100, 20])
|
||||
assert.ok(groups[0].items.every((item) => item.width === '100%'))
|
||||
})
|
||||
|
||||
test('筛选条件和时间窗口可以通过 URL 恢复', () => {
|
||||
const filters = readValueFiltersFromQuery({
|
||||
value_department_id: 'dept-7',
|
||||
value_value_kind: 'cash',
|
||||
value_status: 'verified'
|
||||
})
|
||||
assert.deepEqual(
|
||||
{ departmentId: filters.departmentId, valueKind: filters.valueKind, status: filters.status },
|
||||
{ departmentId: 'dept-7', valueKind: 'cash', status: 'verified' }
|
||||
)
|
||||
|
||||
const query = writeValueFiltersToQuery({ dashboard: 'value', value_city: 'old' }, {
|
||||
...filters,
|
||||
city: ''
|
||||
})
|
||||
assert.equal(query.dashboard, 'value')
|
||||
assert.equal(query.value_department_id, 'dept-7')
|
||||
assert.equal(Object.hasOwn(query, 'value_city'), false)
|
||||
|
||||
const range = resolveRangeWindow('custom', { start: '2026-07-01', end: '2026-07-16' })
|
||||
assert.match(range.start, /^2026-06-30T16:00:00\.000Z$|^2026-07-01T00:00:00\.000Z$/u)
|
||||
assert.ok(new Date(range.end) > new Date(range.start))
|
||||
})
|
||||
|
||||
test('机会抽屉 URL 可恢复、关闭可清理且非法 ID 不会残留', () => {
|
||||
const opportunityId = '247b5e9d-f9ee-463f-a88e-6fd41757769b'
|
||||
const opened = writeValueOpportunityToQuery({ dashboard: 'value', value_city: '上海' }, opportunityId)
|
||||
assert.equal(readValueOpportunityId(opened), opportunityId)
|
||||
assert.equal(hasValueOpportunityQuery(opened), true)
|
||||
|
||||
const closed = writeValueOpportunityToQuery(opened)
|
||||
assert.equal(hasValueOpportunityQuery(closed), false)
|
||||
assert.equal(closed.value_city, '上海')
|
||||
|
||||
const malformed = { ...opened, value_opportunity: '../tenant-b/opportunity' }
|
||||
assert.equal(normalizeValueOpportunityId(malformed.value_opportunity), '')
|
||||
assert.equal(readValueOpportunityId(malformed), '')
|
||||
assert.equal(hasValueOpportunityQuery(writeValueOpportunityToQuery(malformed)), false)
|
||||
assert.equal(shouldClearValueOpportunityError({ status: 403 }), true)
|
||||
assert.equal(shouldClearValueOpportunityError({ status: 404 }), true)
|
||||
assert.equal(shouldClearValueOpportunityError({ status: 500 }), false)
|
||||
})
|
||||
|
||||
test('打开机会必须仍属于当前筛选和时间窗口', () => {
|
||||
const opportunity = {
|
||||
departmentId: 'ignored-top-level',
|
||||
dimensionJson: {
|
||||
department_id: 'dept-7',
|
||||
project_code: 'PROJECT-1',
|
||||
expense_type: 'hotel',
|
||||
supplier_id: 'supplier-1',
|
||||
city: '上海'
|
||||
},
|
||||
ownerId: 'finance-1',
|
||||
sourceType: 'risk_observation',
|
||||
valueKind: 'cash',
|
||||
status: 'identified',
|
||||
createdAt: '2026-07-15T08:00:00Z'
|
||||
}
|
||||
const filters = {
|
||||
departmentId: 'dept-7', projectCode: 'PROJECT-1', expenseType: 'hotel',
|
||||
supplierId: 'supplier-1', city: '上海', ownerId: 'finance-1',
|
||||
sourceType: 'risk_observation', valueKind: 'cash', status: 'identified'
|
||||
}
|
||||
const window = { start: '2026-07-01T00:00:00Z', end: '2026-07-16T23:59:59Z' }
|
||||
|
||||
assert.equal(opportunityMatchesValueContext(opportunity, filters, window), true)
|
||||
assert.equal(opportunityMatchesValueContext(opportunity, { ...filters, departmentId: 'dept-other' }, window), false)
|
||||
assert.equal(opportunityMatchesValueContext(opportunity, filters, { ...window, end: '2026-07-14T00:00:00Z' }), false)
|
||||
})
|
||||
|
||||
test('来源动作可定位风险单据、预算配置和 CFO 维度且不伪造预算事实', () => {
|
||||
const opportunityId = '247b5e9d-f9ee-463f-a88e-6fd41757769b'
|
||||
const links = buildCfoOpportunitySourceLinks({
|
||||
id: opportunityId,
|
||||
claimId: '8a25e85a-b7d8-41dc-8717-57bd1d458fd2',
|
||||
claimNoSnapshot: 'BX-20260716-001',
|
||||
expenseCaseId: '04452d7a-cdb8-4665-bb9d-99b7f8fc7f1b',
|
||||
aiDecisionId: 'b100ca12-8ce3-47eb-a80d-35883410545a',
|
||||
sourceType: 'risk_observation',
|
||||
sourceId: 'risk-fallback',
|
||||
category: 'risk_avoidance',
|
||||
ownerId: 'finance-1',
|
||||
dimensionJson: {
|
||||
department_id: 'dept-7',
|
||||
department_name: '财务部',
|
||||
project_code: 'PROJECT-1',
|
||||
expense_type: 'hotel',
|
||||
city: '上海'
|
||||
},
|
||||
evidence: [{ resourceType: 'risk_observation', resourceId: 'risk-observation-7' }]
|
||||
}, {
|
||||
dashboardQuery: {
|
||||
dashboard: 'value',
|
||||
range: '本月',
|
||||
value_city: '上海',
|
||||
value_opportunity: opportunityId
|
||||
}
|
||||
})
|
||||
|
||||
const risk = links.find((item) => item.key === 'risk-claim')
|
||||
assert.equal(risk.label, '查看风险来源单据')
|
||||
assert.equal(risk.to.name, 'app-document-detail')
|
||||
assert.equal(risk.to.params.requestId, '8a25e85a-b7d8-41dc-8717-57bd1d458fd2')
|
||||
assert.equal(risk.to.query.returnTo, 'value')
|
||||
assert.equal(risk.to.query.focus, 'risk')
|
||||
assert.equal(risk.to.query.risk_observation_id, 'risk-observation-7')
|
||||
assert.equal(risk.to.query.ai_decision_id, 'b100ca12-8ce3-47eb-a80d-35883410545a')
|
||||
assert.equal(risk.to.hash, '#risk-observation-active-detail')
|
||||
|
||||
const budget = links.find((item) => item.key === 'budget-configuration')
|
||||
assert.equal(budget.label, '查看预算配置视图')
|
||||
assert.equal(budget.to.name, 'app-budget')
|
||||
assert.equal(budget.to.query.budget_department_id, 'dept-7')
|
||||
assert.equal(budget.to.query.budget_expense_type, 'hotel')
|
||||
assert.equal(budget.description, BUDGET_CONFIGURATION_NOTICE)
|
||||
assert.match(budget.description, /不代表当前节省机会的真实预算金额/u)
|
||||
|
||||
const dimension = links.find((item) => item.key === 'dimension-expenseType')
|
||||
assert.equal(dimension.to.name, 'app-overview')
|
||||
assert.equal(dimension.to.query.dashboard, 'value')
|
||||
assert.equal(dimension.to.query.value_expense_type, 'hotel')
|
||||
assert.equal(Object.hasOwn(dimension.to.query, 'value_opportunity'), false)
|
||||
|
||||
assert.deepEqual(readBudgetConfigurationFocus(budget.to.query), {
|
||||
active: true,
|
||||
departmentId: 'dept-7',
|
||||
departmentName: '财务部',
|
||||
expenseType: 'hotel'
|
||||
})
|
||||
})
|
||||
|
||||
test('带可追溯凭证的实际结果递归序列化为后端 snake_case', async () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
let requestBody = null
|
||||
globalThis.fetch = async (_url, options) => {
|
||||
requestBody = JSON.parse(options.body)
|
||||
return new Response(JSON.stringify({ realization: {}, opportunity: {}, event: {}, replayed: false }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
await recordSavingsRealization('opp-1', {
|
||||
requestId: 'request-12345678',
|
||||
expectedVersion: 2,
|
||||
comment: '平台付款完成',
|
||||
actualGross: '100',
|
||||
incrementalCost: '10',
|
||||
realizedAt: '2026-07-16T00:00:00Z',
|
||||
evidenceLevel: 'external_document',
|
||||
evidence: [{
|
||||
evidenceKey: 'payment-receipt-001',
|
||||
evidenceRole: 'payment_receipt',
|
||||
resourceType: 'payment_receipt',
|
||||
resourceId: 'receipt-001',
|
||||
sourceSystem: 'erp',
|
||||
contentHash: '0123456789abcdef0123456789abcdef',
|
||||
occurredAt: '2026-07-16T00:00:00Z',
|
||||
verificationStatus: 'unverified',
|
||||
metadataJson: { connector: 'erp' }
|
||||
}]
|
||||
})
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
|
||||
assert.equal(requestBody.request_id, 'request-12345678')
|
||||
assert.equal(requestBody.expected_version, 2)
|
||||
assert.equal(requestBody.incremental_cost, '10')
|
||||
assert.equal(requestBody.evidence_level, 'external_document')
|
||||
assert.equal(requestBody.evidence[0].evidence_key, 'payment-receipt-001')
|
||||
assert.equal(requestBody.evidence[0].metadata_json.connector, 'erp')
|
||||
})
|
||||
|
||||
test('经营价值入口、独立模块、URL 深链与完整状态 UI 已接入分析看板', () => {
|
||||
assert.match(topBarRange, /label: '经营价值看板', value: 'value'/u)
|
||||
assert.match(overviewView, /<CfoValueDashboard/u)
|
||||
assert.match(overviewView, /props\.dashboard === 'value'/u)
|
||||
assert.match(appShell, /OVERVIEW_DASHBOARDS = new Set\(\['finance', 'value'/u)
|
||||
assert.match(appShell, /dashboard: resolveOverviewDashboard\(overviewDashboard\.value\)/u)
|
||||
assert.match(dashboardComposable, /readValueFiltersFromQuery\(route\.query\)/u)
|
||||
assert.match(dashboardComposable, /writeValueFiltersToQuery\(route\.query/u)
|
||||
assert.match(dashboardComponent, /dashboardState === 'loading'/u)
|
||||
assert.match(dashboardComponent, /dashboardState === 'permission'/u)
|
||||
assert.match(dashboardComponent, /dashboardState === 'error'/u)
|
||||
assert.match(dashboardComponent, /dashboardState === 'empty'/u)
|
||||
assert.match(dashboardComponent, /dashboardState === 'partial'/u)
|
||||
assert.match(dashboardComponent, /dashboardStale/u)
|
||||
assert.match(dashboardComponent, /kpi\.state/u)
|
||||
assert.match(dashboardComponent, /机会状态 <em>仅筛选下方台账<\/em>/u)
|
||||
assert.match(dashboardModel, /baseline-missing/u)
|
||||
assert.match(dashboardModel, /真实零值/u)
|
||||
assert.match(dashboardComposable, /valueOpportunityRouteSignature/u)
|
||||
assert.match(dashboardComposable, /clearUnavailableOpportunity/u)
|
||||
assert.match(dashboardComposable, /router\.replace\(\{ query: nextQuery \}\)/u)
|
||||
assert.match(appShellComposable, /DOCUMENT_DETAIL_RETURN_TARGETS = new Set\(\['workbench', 'conversation', 'value'\]\)/u)
|
||||
assert.match(appShellComposable, /name: 'app-overview', query: buildValueDashboardReturnQuery\(\)/u)
|
||||
assert.match(opportunityDrawer, /id="value-source-links-title">查看来源/u)
|
||||
assert.match(opportunityDrawer, /\{\{ link\.label \}\}/u)
|
||||
assert.match(budgetCenter, /预算配置视图 · \{\{ budgetConfigurationFocusSummary \}\}/u)
|
||||
assert.match(budgetCenter, /isFocusedBudgetCategory\(item\)/u)
|
||||
assert.match(budgetCenterScript, /readBudgetConfigurationFocus\(route\.query\)/u)
|
||||
assert.match(budgetCenterScript, /\.filter\(\(row\) => matchesBudgetConfigurationExpense\(row\)\)/u)
|
||||
assert.match(budgetCenterScript, /这不代表预算为零/u)
|
||||
})
|
||||
|
||||
test('经营价值实现不包含 demo 或 fallback 指标数字', () => {
|
||||
assert.doesNotMatch(valueService, /FALLBACK|DEMO|mock/iu)
|
||||
assert.doesNotMatch(dashboardComposable, /fallback|demo|mock/iu)
|
||||
assert.match(dashboardComponent, /不会展示旧的演示数字/u)
|
||||
})
|
||||
|
||||
test('移动端、键盘与读屏交互具备可验证的生产语义', () => {
|
||||
assert.match(dashboardStyles, /@media \(max-width: 760px\)/u)
|
||||
assert.match(dashboardStyles, /min-height: 44px/u)
|
||||
assert.match(dashboardComponent, /role="alert"/u)
|
||||
assert.match(dashboardComponent, /aria-live="polite"/u)
|
||||
assert.match(actionDialog, /<label/u)
|
||||
assert.match(actionDialog, /role="alert"/u)
|
||||
assert.match(actionDialog, /:disabled="submitting"/u)
|
||||
assert.match(actionDialog, /requestId: form\.requestId/u)
|
||||
assert.match(dashboardComposable, /command\.requestId \|\| createRequestId/u)
|
||||
assert.doesNotMatch(dashboardComposable, /recordSavingsRealization/u)
|
||||
assert.match(dashboardComposable, /实际结果必须由付款事件或具备可追溯凭证的连接器写入/u)
|
||||
assert.match(opportunityDrawer, /action !== 'record_realization'/u)
|
||||
assert.match(opportunityDrawer, /当前页面不会提交空证据/u)
|
||||
assert.match(opportunityDrawer, /financeConfirmerName/u)
|
||||
assert.match(opportunityDrawer, /确认说明:/u)
|
||||
assert.doesNotMatch(actionDialog, /实际毛节省/u)
|
||||
assert.match(opportunityDrawer, /aria-label="关闭节省机会详情"/u)
|
||||
assert.match(trendChart, /role="img"/u)
|
||||
assert.match(trendChart, /查看趋势明细表/u)
|
||||
assert.match(trendChart, /<caption class="sr-only">/u)
|
||||
})
|
||||
|
||||
function dashboardFixture({
|
||||
dataStatus,
|
||||
opportunityCount = 1,
|
||||
realizationCount = 1,
|
||||
freshnessAt = '2026-07-16T00:00:00Z'
|
||||
}) {
|
||||
return {
|
||||
source: { dataStatus, opportunityCount, realizationCount, freshnessAt },
|
||||
kpis: {},
|
||||
funnel: { stages: [] },
|
||||
trend: [],
|
||||
breakdowns: [],
|
||||
guardrails: [],
|
||||
dataQuality: {}
|
||||
}
|
||||
}
|
||||
|
||||
function readSource(path) {
|
||||
return readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
|
||||
}
|
||||
140
web/tests/commercial-components-compile.test.mjs
Normal file
140
web/tests/commercial-components-compile.test.mjs
Normal file
@@ -0,0 +1,140 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { compileScript, compileTemplate, parse } from '@vue/compiler-sfc'
|
||||
|
||||
const componentFiles = [
|
||||
'../src/components/commercial/CommercialWorkspace.vue',
|
||||
'../src/components/commercial/CommercialAccountOverview.vue',
|
||||
'../src/components/commercial/CommercialValueProofPanel.vue',
|
||||
'../src/components/commercial/CommercialPricingScenarioPanel.vue',
|
||||
'../src/components/commercial/CommercialMetricCard.vue',
|
||||
'../src/components/commercial/CommercialAdminConsole.vue',
|
||||
'../src/components/commercial/CommercialHistoryPanel.vue',
|
||||
'../src/components/commercial/CommercialMutationDialog.vue'
|
||||
]
|
||||
|
||||
for (const relativeFile of componentFiles) {
|
||||
test(`${relativeFile} 可以独立编译`, () => {
|
||||
const filename = fileURLToPath(new URL(relativeFile, import.meta.url))
|
||||
const source = readFileSync(filename, 'utf8')
|
||||
const parsed = parse(source, { filename })
|
||||
assert.deepEqual(parsed.errors, [])
|
||||
const id = `commercial-${relativeFile.replace(/\W+/gu, '-')}`
|
||||
const script = compileScript(parsed.descriptor, { id })
|
||||
const template = compileTemplate({
|
||||
source: parsed.descriptor.template?.content || '',
|
||||
filename,
|
||||
id,
|
||||
compilerOptions: { bindingMetadata: script.bindings }
|
||||
})
|
||||
assert.deepEqual(template.errors, [])
|
||||
})
|
||||
}
|
||||
|
||||
const workspace = readSource('../src/components/commercial/CommercialWorkspace.vue')
|
||||
const valuePanel = readSource('../src/components/commercial/CommercialValueProofPanel.vue')
|
||||
const pricingPanel = readSource('../src/components/commercial/CommercialPricingScenarioPanel.vue')
|
||||
const metricCard = readSource('../src/components/commercial/CommercialMetricCard.vue')
|
||||
const adminConsole = readSource('../src/components/commercial/CommercialAdminConsole.vue')
|
||||
const mutationDialog = readSource('../src/components/commercial/CommercialMutationDialog.vue')
|
||||
const historyPanel = readSource('../src/components/commercial/CommercialHistoryPanel.vue')
|
||||
const styles = readSource('../src/components/commercial/commercial-workspace.css')
|
||||
const overviewView = readSource('../src/views/OverviewView.vue')
|
||||
const appShell = readSource('../src/views/AppShellRouteView.vue')
|
||||
const topBarOverviewRange = readSource('../src/components/layout/useTopBarOverviewRange.js')
|
||||
const overviewModel = readSource('../src/composables/useOverviewView.js')
|
||||
|
||||
test('商业工作台具有显式租户选择、双账分离、权限和完整空错状态', () => {
|
||||
assert.match(workspace, /目标租户 ID/u)
|
||||
assert.match(workspace, /平台管理员必须显式选择租户/u)
|
||||
assert.match(workspace, /商业权益不能放宽安全规则/u)
|
||||
assert.match(valuePanel, /客户价值账/u)
|
||||
assert.match(valuePanel, /平台经营账/u)
|
||||
assert.match(valuePanel, /不同币种不会混算/u)
|
||||
assert.match(valuePanel, /!platformAdmin/u)
|
||||
assert.match(valuePanel, /role="alert"/u)
|
||||
assert.match(valuePanel, /这不是零成本或零价值/u)
|
||||
})
|
||||
|
||||
test('无真实财务证据显示不可用和缺失输入,不含演示或回退指标', () => {
|
||||
assert.match(metricCard, /commercial-unavailable-value">不可用/u)
|
||||
assert.match(metricCard, /查看缺失证据/u)
|
||||
assert.match(metricCard, /requiredInputs/u)
|
||||
assert.doesNotMatch(workspace, /demo|mock|fallback/iu)
|
||||
assert.doesNotMatch(valuePanel, /demo|mock|fallback/iu)
|
||||
})
|
||||
|
||||
test('定价场景具有参数确认、分币种走廊、部分可用与不写套餐语义', () => {
|
||||
assert.match(workspace, /CommercialPricingScenarioPanel/u)
|
||||
for (const label of ['目标贡献毛利率', '最大价值分享比例', '证据截止时间(as_of)', '确认计算条件']) {
|
||||
assert.match(pricingPanel, new RegExp(label, 'u'))
|
||||
}
|
||||
for (const label of ['可持续收费下限', '价值对齐收费上限', '成功费封顶', '推荐商业模式', '缺失输入']) {
|
||||
assert.match(pricingPanel, new RegExp(label, 'u'))
|
||||
}
|
||||
assert.match(pricingPanel, /v-for="row in view\.rows"/u)
|
||||
assert.match(pricingPanel, /row\.currency/u)
|
||||
assert.match(pricingPanel, /evidenceStatus/u)
|
||||
assert.match(pricingPanel, /不可用/u)
|
||||
assert.match(pricingPanel, /不会自动写入套餐/u)
|
||||
assert.match(pricingPanel, /aria-busy/u)
|
||||
assert.match(pricingPanel, /role="alert"/u)
|
||||
assert.match(pricingPanel, /inputmode="decimal"/u)
|
||||
})
|
||||
|
||||
test('所有商业写操作都有操作确认、忙碌态、幂等和版本化提示', () => {
|
||||
for (const label of ['创建套餐版本', '激活套餐版本', '创建订阅快照', '重新激活订阅', '变更订阅状态', '配置权益版本', '激活权益版本', '记录用量事件', '记录成本事件']) {
|
||||
assert.match(readSource('../src/components/commercial/commercialWorkspaceModel.js'), new RegExp(label, 'u'))
|
||||
}
|
||||
assert.match(adminConsole, /二次确认/u)
|
||||
assert.match(mutationDialog, /step === 'edit'/u)
|
||||
assert.match(mutationDialog, /确认写入/u)
|
||||
assert.match(mutationDialog, /幂等键/u)
|
||||
assert.match(mutationDialog, /版本化快照/u)
|
||||
assert.match(mutationDialog, /:disabled="busy"/u)
|
||||
assert.match(mutationDialog, /role="alertdialog"/u)
|
||||
})
|
||||
|
||||
test('五类历史有按需加载、刷新、空态与订阅状态操作入口', () => {
|
||||
for (const label of ['套餐版本', '订阅历史', '权益版本', '用量事件', '成本事件']) {
|
||||
assert.match(historyPanel, new RegExp(label, 'u'))
|
||||
}
|
||||
assert.match(historyPanel, /加载历史/u)
|
||||
assert.match(historyPanel, /刷新历史/u)
|
||||
assert.match(historyPanel, /这不是零用量或零成本/u)
|
||||
assert.match(historyPanel, /manage-subscription/u)
|
||||
assert.match(historyPanel, /变更状态/u)
|
||||
assert.match(historyPanel, /恢复/u)
|
||||
})
|
||||
|
||||
test('商业工作台满足键盘、触控、响应式和减少动画语义', () => {
|
||||
assert.match(styles, /min-height: 44px/u)
|
||||
assert.match(styles, /:focus-visible/u)
|
||||
assert.match(styles, /@media \(max-width: 760px\)/u)
|
||||
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)/u)
|
||||
assert.match(mutationDialog, /@keydown\.esc/u)
|
||||
assert.match(mutationDialog, /aria-label="关闭对话框"/u)
|
||||
assert.match(pricingPanel, /tabindex="-1"/u)
|
||||
assert.match(pricingPanel, /:disabled="loading"/u)
|
||||
})
|
||||
|
||||
test('商业工作台已接入分析看板且不会误加载财务看板', () => {
|
||||
assert.match(overviewView, /CommercialWorkspace/u)
|
||||
assert.match(overviewView, /activeDashboard === 'commercial'/u)
|
||||
assert.match(overviewView, /currentUser: \{ type: Object/u)
|
||||
assert.match(appShell, /:current-user="currentUser"/u)
|
||||
assert.match(appShell, /'commercial'/u)
|
||||
assert.match(topBarOverviewRange, /\{ label: '商业化管理', value: 'commercial' \}/u)
|
||||
assert.match(overviewModel, /dashboard === 'commercial'/u)
|
||||
assert.match(
|
||||
overviewModel,
|
||||
/activeDashboardKey\.value === 'commercial' \|\| activeDashboardKey\.value === 'value'/u
|
||||
)
|
||||
})
|
||||
|
||||
function readSource(relativeFile) {
|
||||
return readFileSync(fileURLToPath(new URL(relativeFile, import.meta.url)), 'utf8')
|
||||
}
|
||||
215
web/tests/commercial-service.test.mjs
Normal file
215
web/tests/commercial-service.test.mjs
Normal file
@@ -0,0 +1,215 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
activateCommercialEntitlement,
|
||||
activateCommercialPlan,
|
||||
activateCommercialSubscription,
|
||||
buildCommercialAnalyticsSearch,
|
||||
buildCommercialPricingScenario,
|
||||
createCommercialPlan,
|
||||
createCommercialSubscription,
|
||||
fetchCommercialAccount,
|
||||
fetchCommercialAnalytics,
|
||||
fetchCommercialCostEvents,
|
||||
fetchCommercialEntitlements,
|
||||
fetchCommercialPlans,
|
||||
fetchCommercialSubscriptions,
|
||||
fetchCommercialUsageEvents,
|
||||
normalizeCommercialPayload,
|
||||
recordCommercialCost,
|
||||
recordCommercialUsage,
|
||||
serializeCommercialPayload,
|
||||
transitionCommercialSubscription,
|
||||
upsertCommercialEntitlement
|
||||
} from '../src/services/commercial.js'
|
||||
|
||||
function installFetch(handler) {
|
||||
const original = globalThis.fetch
|
||||
globalThis.fetch = handler
|
||||
return () => { globalThis.fetch = original }
|
||||
}
|
||||
|
||||
function jsonResponse(payload, status = 200) {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
})
|
||||
}
|
||||
|
||||
test('商业接口递归转换 snake_case 与 camelCase 且保留数组和金额字符串', () => {
|
||||
const normalized = normalizeCommercialPayload({
|
||||
data_status: 'partial',
|
||||
customer_roi: { required_inputs: ['收费'], ratios: [{ reporting_currency: 'CNY', ratio: '1.20' }] }
|
||||
})
|
||||
assert.equal(normalized.dataStatus, 'partial')
|
||||
assert.equal(normalized.customerRoi.requiredInputs[0], '收费')
|
||||
assert.equal(normalized.customerRoi.ratios[0].reportingCurrency, 'CNY')
|
||||
assert.equal(normalized.customerRoi.ratios[0].ratio, '1.20')
|
||||
|
||||
assert.deepEqual(serializeCommercialPayload({
|
||||
planCode: 'enterprise',
|
||||
configJson: { billingMode: 'overage' },
|
||||
requiredInputs: ['invoice']
|
||||
}), {
|
||||
plan_code: 'enterprise',
|
||||
config_json: { billing_mode: 'overage' },
|
||||
required_inputs: ['invoice']
|
||||
})
|
||||
})
|
||||
|
||||
test('普通账号只读取自身账户,平台管理员显式选择租户后才使用跨租户路径', async () => {
|
||||
const urls = []
|
||||
const restore = installFetch(async (url) => {
|
||||
urls.push(String(url))
|
||||
return jsonResponse({ tenant_id: 'tenant-a', data_status: 'unavailable', quotas: [] })
|
||||
})
|
||||
try {
|
||||
await fetchCommercialAccount({ platformAdmin: false, tenantId: 'should-not-leak' })
|
||||
await fetchCommercialAccount({ platformAdmin: true, tenantId: 'tenant/a', asOf: '2026-07-16T00:00:00Z' })
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
|
||||
assert.equal(urls[0], '/api/v1/commercial/account')
|
||||
assert.match(urls[1], /\/commercial\/admin\/tenants\/tenant%2Fa\/account\?as_of=2026-07-16T00%3A00%3A00Z$/u)
|
||||
})
|
||||
|
||||
test('商业分析时间参数显式序列化且不附加客户端汇率或合计字段', async () => {
|
||||
const search = buildCommercialAnalyticsSearch({
|
||||
start: '2026-04-01T00:00:00Z',
|
||||
end: '2026-07-01T00:00:00Z',
|
||||
asOf: '2026-07-01T00:00:00Z',
|
||||
fxRate: '7.2'
|
||||
})
|
||||
assert.equal(search.get('as_of'), '2026-07-01T00:00:00Z')
|
||||
assert.equal(search.has('fx_rate'), false)
|
||||
|
||||
let requestedUrl = ''
|
||||
const restore = installFetch(async (url) => {
|
||||
requestedUrl = String(url)
|
||||
return jsonResponse({
|
||||
tenant_id: 'tenant-a',
|
||||
data_quality_status: 'unavailable',
|
||||
customer_charges: {},
|
||||
internal_costs: {},
|
||||
contribution_margin: {},
|
||||
verified_cash_savings: {},
|
||||
customer_roi: {},
|
||||
customer_labor_value: {}
|
||||
})
|
||||
})
|
||||
try {
|
||||
await fetchCommercialAnalytics('tenant-a', Object.fromEntries(search))
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
assert.match(requestedUrl, /\/commercial\/admin\/tenants\/tenant-a\/analytics/u)
|
||||
assert.match(requestedUrl, /start=2026-04-01T00%3A00%3A00Z/u)
|
||||
})
|
||||
|
||||
test('定价场景使用平台租户 POST 契约且只提交服务端 schema 字段', async () => {
|
||||
let request = null
|
||||
const restore = installFetch(async (url, options = {}) => {
|
||||
request = { url: String(url), method: options.method, body: JSON.parse(options.body) }
|
||||
return jsonResponse({
|
||||
tenant_id: 'tenant-a',
|
||||
recommended_model: 'pilot_collecting',
|
||||
evidence_status: 'unavailable',
|
||||
scenarios: [],
|
||||
notes: ['不会自动修改套餐']
|
||||
})
|
||||
})
|
||||
try {
|
||||
const response = await buildCommercialPricingScenario('tenant/a', {
|
||||
start: '2026-04-01T00:00:00Z',
|
||||
end: '2026-07-01T23:59:59Z',
|
||||
asOf: '2026-07-02T00:00:00Z',
|
||||
targetContributionMarginRate: '0.65',
|
||||
maxVerifiedSavingsShare: '0.25'
|
||||
})
|
||||
assert.equal(response.recommendedModel, 'pilot_collecting')
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
|
||||
assert.equal(request.method, 'POST')
|
||||
assert.match(request.url, /\/commercial\/admin\/tenants\/tenant%2Fa\/pricing-scenarios$/u)
|
||||
assert.deepEqual(Object.keys(request.body).sort(), [
|
||||
'as_of',
|
||||
'end',
|
||||
'max_verified_savings_share',
|
||||
'start',
|
||||
'target_contribution_margin_rate'
|
||||
])
|
||||
assert.equal(request.body.target_contribution_margin_rate, '0.65')
|
||||
assert.equal(request.body.max_verified_savings_share, '0.25')
|
||||
})
|
||||
|
||||
test('商业写接口覆盖套餐、订阅、权益、激活、用量与成本并统一递归序列化', async () => {
|
||||
const requests = []
|
||||
const restore = installFetch(async (url, options = {}) => {
|
||||
requests.push({ url: String(url), method: options.method, headers: options.headers, body: JSON.parse(options.body) })
|
||||
return jsonResponse({ id: 'resource-1', created: true })
|
||||
})
|
||||
try {
|
||||
await createCommercialPlan('tenant-a', { planCode: 'enterprise', contractTermsJson: { approvedBy: 'cfo' } })
|
||||
await activateCommercialPlan('tenant-a', 'plan-1', 2, '启用新套餐版本')
|
||||
await createCommercialSubscription('tenant-a', { subscriptionKey: 'sub-1', autoRenew: true })
|
||||
await activateCommercialSubscription('tenant-a', 'sub-1', 3, '恢复客户订阅')
|
||||
await transitionCommercialSubscription('tenant-a', 'sub-1', { expectedVersion: 4, targetStatus: 'suspended', reason: '合同暂停' })
|
||||
await upsertCommercialEntitlement('tenant-a', { entitlementKey: 'ocr', configJson: { billingMode: 'all_usage' } })
|
||||
await activateCommercialEntitlement('tenant-a', 'ent-1', 4, '恢复 OCR 权益')
|
||||
await recordCommercialUsage('tenant-a', { idempotencyKey: 'usage-1', subjectType: 'claim' })
|
||||
await recordCommercialCost('tenant-a', { idempotencyKey: 'cost-1', originalCurrency: 'CNY' })
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
|
||||
assert.deepEqual(requests.map((item) => item.method), ['POST', 'POST', 'POST', 'POST', 'POST', 'PUT', 'POST', 'POST', 'POST'])
|
||||
assert.match(requests[0].url, /\/plans$/u)
|
||||
assert.equal(requests[0].body.contract_terms_json.approved_by, 'cfo')
|
||||
assert.equal(requests[1].body.expected_version, 2)
|
||||
assert.equal(requests[1].body.reason, '启用新套餐版本')
|
||||
assert.ok(requests.every((item) => item.headers['X-Request-Id']))
|
||||
assert.equal(requests[2].body.auto_renew, true)
|
||||
assert.match(requests[4].url, /\/subscriptions\/sub-1\/transition$/u)
|
||||
assert.equal(requests[4].body.target_status, 'suspended')
|
||||
assert.equal(requests[5].body.config_json.billing_mode, 'all_usage')
|
||||
assert.match(requests[7].url, /\/usage-events$/u)
|
||||
assert.match(requests[8].url, /\/cost-events$/u)
|
||||
})
|
||||
|
||||
test('五类商业历史按最终分资源契约读取并正确序列化筛选', async () => {
|
||||
const urls = []
|
||||
const restore = installFetch(async (url) => {
|
||||
urls.push(String(url))
|
||||
return jsonResponse([])
|
||||
})
|
||||
try {
|
||||
await fetchCommercialPlans('tenant-a', { planStatus: 'active', limit: 20, offset: 1 })
|
||||
await fetchCommercialSubscriptions('tenant-a', { subscriptionStatus: 'suspended' })
|
||||
await fetchCommercialEntitlements('tenant-a', { subscriptionId: 'sub-1', entitlementStatus: 'active' })
|
||||
await fetchCommercialUsageEvents('tenant-a', { subscriptionId: 'sub-1', start: '2026-07-01T00:00:00Z', end: '2026-08-01T00:00:00Z' })
|
||||
await fetchCommercialCostEvents('tenant-a', { limit: 50 })
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
assert.match(urls[0], /\/plans\?plan_status=active&limit=20&offset=1$/u)
|
||||
assert.match(urls[1], /\/subscriptions\?subscription_status=suspended$/u)
|
||||
assert.match(urls[2], /\/entitlements\?subscription_id=sub-1&entitlement_status=active$/u)
|
||||
assert.match(urls[3], /\/usage-events\?subscription_id=sub-1&start=.*&end=.*/u)
|
||||
assert.match(urls[4], /\/cost-events\?limit=50$/u)
|
||||
})
|
||||
|
||||
test('后端权限和冲突错误保留状态与可执行提示', async () => {
|
||||
const restore = installFetch(async () => jsonResponse({ detail: '只有平台管理员可以执行该操作。' }, 403))
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => fetchCommercialAnalytics('tenant-a'),
|
||||
(error) => error.status === 403 && /平台管理员/u.test(error.message)
|
||||
)
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
})
|
||||
269
web/tests/commercial-workspace-model.test.mjs
Normal file
269
web/tests/commercial-workspace-model.test.mjs
Normal file
@@ -0,0 +1,269 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
buildAnalyticsWindow,
|
||||
buildCommercialMutation,
|
||||
buildCommercialValueLedgers,
|
||||
buildPricingScenarioPayload,
|
||||
buildPricingScenarioView,
|
||||
buildQuotaRows,
|
||||
classifyCommercialAccountState,
|
||||
createDefaultPricingScenarioForm,
|
||||
formatCurrencyAmount,
|
||||
formatMetricValues,
|
||||
formatRatio
|
||||
} from '../src/components/commercial/commercialWorkspaceModel.js'
|
||||
|
||||
test('客户价值账和平台经营账保持严格分离', () => {
|
||||
const ledgers = buildCommercialValueLedgers({
|
||||
verifiedCashSavings: metric('verified_cash_savings', '客户财务确认现金节省', [{ currency: 'CNY', amount: '100' }]),
|
||||
customerCharges: metric('customer_charges', '客户合同收费', [{ currency: 'CNY', amount: '40' }], 'partial'),
|
||||
customerRoi: { ...metric('customer_roi', '客户现金 ROI', [], 'partial'), ratios: [{ currency: 'CNY', ratio: '1.5', numerator: '60', denominator: '40' }] },
|
||||
customerLaborValue: unavailable('customer_labor_value', '客户认可工时价值'),
|
||||
internalCosts: metric('internal_costs', '平台内部成本', [{ currency: 'CNY', amount: '12' }]),
|
||||
contributionMargin: metric('contribution_margin', '贡献毛利', [{ currency: 'CNY', amount: '28' }], 'partial')
|
||||
})
|
||||
|
||||
assert.deepEqual(ledgers.customer.map((item) => item.category), [
|
||||
'verifiedCashSavings', 'customerCharges', 'customerRoi', 'customerLaborValue'
|
||||
])
|
||||
assert.deepEqual(ledgers.platform.map((item) => item.category), ['internalCosts', 'contributionMargin'])
|
||||
assert.equal(ledgers.customer[2].displayValue, '150.0%')
|
||||
assert.equal(ledgers.platform[0].displayValue, formatCurrencyAmount('12', 'CNY'))
|
||||
})
|
||||
|
||||
test('不同币种逐行保留,绝不在前端求和或换算', () => {
|
||||
const values = formatMetricValues(metric('verified', '已核验', [
|
||||
{ currency: 'CNY', amount: '100' },
|
||||
{ currency: 'USD', amount: '20' }
|
||||
]))
|
||||
assert.deepEqual(values.map((item) => item.currency), ['CNY', 'USD'])
|
||||
assert.deepEqual(values.map((item) => item.amount), [100, 20])
|
||||
assert.match(values[0].displayValue, /100/u)
|
||||
assert.match(values[1].displayValue, /20/u)
|
||||
assert.equal(formatRatio('0.25'), '25.0%')
|
||||
})
|
||||
|
||||
test('没有真实财务证据时明确不可用,不把未知伪装成零', () => {
|
||||
const [cash, labor] = buildCommercialValueLedgers({
|
||||
verifiedCashSavings: unavailable('verified_cash_savings', '客户财务确认现金节省'),
|
||||
customerCharges: unavailable('customer_charges', '客户合同收费'),
|
||||
customerRoi: unavailable('customer_roi', '客户现金 ROI'),
|
||||
customerLaborValue: unavailable('customer_labor_value', '客户认可工时价值'),
|
||||
internalCosts: unavailable('internal_costs', '平台内部成本'),
|
||||
contributionMargin: unavailable('contribution_margin', '贡献毛利')
|
||||
}).customer
|
||||
|
||||
assert.equal(cash.status, 'unavailable')
|
||||
assert.equal(cash.displayValue, '不可用')
|
||||
assert.deepEqual(cash.values, [])
|
||||
assert.equal(labor.displayValue, '不可用')
|
||||
assert.deepEqual(labor.requiredInputs, ['真实财务证据'])
|
||||
})
|
||||
|
||||
test('账户状态完整区分加载、权限、错误、未配置、部分与就绪', () => {
|
||||
assert.equal(classifyCommercialAccountState({ loading: true }), 'loading')
|
||||
assert.equal(classifyCommercialAccountState({ error: { status: 403 } }), 'permission')
|
||||
assert.equal(classifyCommercialAccountState({ error: new Error('offline') }), 'error')
|
||||
assert.equal(classifyCommercialAccountState({ account: { dataStatus: 'unavailable' } }), 'unavailable')
|
||||
assert.equal(classifyCommercialAccountState({ account: { dataStatus: 'partial' } }), 'partial')
|
||||
assert.equal(classifyCommercialAccountState({ account: { dataStatus: 'available' } }), 'ready')
|
||||
})
|
||||
|
||||
test('权益列表保持失败关闭语义并区分不限量和未知硬上限', () => {
|
||||
const rows = buildQuotaRows({ quotas: [
|
||||
{ status: 'unlimited', usedQuantity: '12', hardLimitRemaining: null, commerciallyAllowed: true, entitlement: { id: 'e1', entitlementKey: 'ocr', metricKey: 'ocr_calls', unit: '次', entitlementType: 'unlimited', version: 1 } },
|
||||
{ status: 'inactive', usedQuantity: '0', hardLimitRemaining: null, commerciallyAllowed: false, reason: '订阅暂停', entitlement: { id: 'e2', entitlementKey: 'ai', metricKey: 'ai_calls', unit: '次', entitlementType: 'metered', version: 2 } }
|
||||
] })
|
||||
assert.equal(rows[0].remainingLabel, '不限量')
|
||||
assert.equal(rows[1].remainingLabel, '未配置硬上限')
|
||||
assert.equal(rows[1].commerciallyAllowed, false)
|
||||
assert.equal(rows[1].reason, '订阅暂停')
|
||||
})
|
||||
|
||||
test('分析窗口生成显式时区并拒绝倒序和超过两年', () => {
|
||||
const window = buildAnalyticsWindow({ start: '2026-04-01', end: '2026-07-01' })
|
||||
assert.match(window.start, /(Z|[+-]\d{2}:\d{2})$/u)
|
||||
assert.match(window.end, /(Z|[+-]\d{2}:\d{2})$/u)
|
||||
assert.throws(() => buildAnalyticsWindow({ start: '2026-07-01', end: '2026-04-01' }), /早于/u)
|
||||
assert.throws(() => buildAnalyticsWindow({ start: '2023-01-01', end: '2026-07-01' }), /731/u)
|
||||
})
|
||||
|
||||
test('定价场景将百分比和时间窗转换为后端精确契约并执行前置校验', () => {
|
||||
const defaults = createDefaultPricingScenarioForm(
|
||||
{ start: '2026-04-01', end: '2026-07-01' },
|
||||
new Date('2026-07-02T08:30:00Z')
|
||||
)
|
||||
assert.equal(defaults.targetContributionMarginPercent, '65')
|
||||
assert.equal(defaults.maxVerifiedSavingsSharePercent, '25')
|
||||
|
||||
const payload = buildPricingScenarioPayload({
|
||||
...defaults,
|
||||
asOf: '2026-07-02T08:30:00Z',
|
||||
targetContributionMarginPercent: '65',
|
||||
maxVerifiedSavingsSharePercent: '25'
|
||||
})
|
||||
assert.match(payload.start, /(Z|[+-]\d{2}:\d{2})$/u)
|
||||
assert.match(payload.end, /(Z|[+-]\d{2}:\d{2})$/u)
|
||||
assert.equal(payload.asOf, '2026-07-02T08:30:00.000Z')
|
||||
assert.equal(payload.targetContributionMarginRate, '0.65')
|
||||
assert.equal(payload.maxVerifiedSavingsShare, '0.25')
|
||||
|
||||
assert.throws(() => buildPricingScenarioPayload({
|
||||
...defaults, targetContributionMarginPercent: '95'
|
||||
}), /小于 95/u)
|
||||
assert.throws(() => buildPricingScenarioPayload({
|
||||
...defaults, targetContributionMarginPercent: '94.999999'
|
||||
}), /小于 95/u)
|
||||
assert.equal(buildPricingScenarioPayload({
|
||||
...defaults, targetContributionMarginPercent: '94.9999'
|
||||
}).targetContributionMarginRate, '0.949999')
|
||||
assert.throws(() => buildPricingScenarioPayload({
|
||||
...defaults, maxVerifiedSavingsSharePercent: '0'
|
||||
}), /大于 0/u)
|
||||
assert.throws(() => buildPricingScenarioPayload({
|
||||
...defaults, asOf: '2026-03-01T00:00:00Z'
|
||||
}), /不能早于/u)
|
||||
})
|
||||
|
||||
test('定价结果逐币种展示价格走廊且绝不把缺失事实显示为零', () => {
|
||||
const view = buildPricingScenarioView({
|
||||
tenantId: 'tenant-a',
|
||||
start: '2026-04-01T00:00:00Z',
|
||||
end: '2026-07-01T23:59:59Z',
|
||||
asOf: '2026-07-02T00:00:00Z',
|
||||
targetContributionMarginRate: '0.65',
|
||||
maxVerifiedSavingsShare: '0.25',
|
||||
recommendedModel: 'hybrid',
|
||||
evidenceStatus: 'partial',
|
||||
scenarios: [
|
||||
{
|
||||
currency: 'CNY', status: 'feasible', internalCost: '20', verifiedCashSavings: '200',
|
||||
minimumSustainableCharge: '57.1429', maximumValueAlignedCharge: '50',
|
||||
maximumSuccessFee: '0', customerRoiAtMinimumCharge: '2.5',
|
||||
contributionMarginAtValueCeiling: '0.6', reason: '独立 CNY 走廊'
|
||||
},
|
||||
{
|
||||
currency: 'USD', status: 'cost_only', internalCost: '5', verifiedCashSavings: null,
|
||||
minimumSustainableCharge: '14.2857', maximumValueAlignedCharge: null,
|
||||
maximumSuccessFee: null, customerRoiAtMinimumCharge: null,
|
||||
contributionMarginAtValueCeiling: null, reason: '缺少 USD 价值证据'
|
||||
}
|
||||
],
|
||||
notes: ['结果不会自动写套餐']
|
||||
})
|
||||
|
||||
assert.equal(view.recommendedModelLabel, '混合定价(基础订阅 + 封顶成功费)')
|
||||
assert.equal(view.multiCurrency, true)
|
||||
assert.deepEqual(view.rows.map((row) => row.currency), ['CNY', 'USD'])
|
||||
assert.match(view.rows[0].minimumSustainableChargeLabel, /57/u)
|
||||
assert.equal(view.rows[1].maximumValueAlignedChargeLabel, '不可用')
|
||||
assert.equal(view.rows[1].maximumSuccessFeeLabel, '不可用')
|
||||
assert.equal(view.rows[1].customerRoiLabel, '—')
|
||||
assert.deepEqual(view.rows[1].missingInputs, ['同币种财务确认现金节省'])
|
||||
assert.doesNotMatch(view.rows[1].maximumValueAlignedChargeLabel, /0/u)
|
||||
})
|
||||
|
||||
test('完全无证据的定价响应保留 unavailable 与缺失说明', () => {
|
||||
const view = buildPricingScenarioView({
|
||||
recommendedModel: 'pilot_collecting', evidenceStatus: 'unavailable', scenarios: [], notes: []
|
||||
})
|
||||
assert.equal(view.evidenceStatus, 'unavailable')
|
||||
assert.equal(view.evidenceStatusLabel, '不可用')
|
||||
assert.equal(view.recommendedModelLabel, '试点采集证据')
|
||||
assert.deepEqual(view.rows, [])
|
||||
})
|
||||
|
||||
test('套餐、订阅与权益表单构造服务端事实时间和版本化载荷', () => {
|
||||
const plan = buildCommercialMutation('createPlan', {
|
||||
planCode: 'enterprise', name: '企业版', pricingModel: 'hybrid', billingInterval: 'annual',
|
||||
currency: 'cny', baseFee: '1000', includedSeats: 20, overageEnabled: true,
|
||||
effectiveFrom: '2026-07-16T09:30', effectiveTo: '', contractTermsJson: '{"approved_by":"cfo"}'
|
||||
})
|
||||
assert.equal(plan.currency, 'CNY')
|
||||
assert.match(plan.effectiveFrom, /Z$/u)
|
||||
assert.equal(plan.contractTermsJson.approved_by, 'cfo')
|
||||
|
||||
const subscription = buildCommercialMutation('createSubscription', {
|
||||
subscriptionKey: 'sub-enterprise', status: 'active', startsAt: '2026-07-16T09:30',
|
||||
currentPeriodStart: '2026-07-16T09:30', currentPeriodEnd: '2026-08-16T09:30',
|
||||
seats: 20, metadataJson: '{}'
|
||||
}, { plan: { id: 'plan-1' } })
|
||||
assert.equal(subscription.planId, 'plan-1')
|
||||
assert.match(subscription.currentPeriodEnd, /Z$/u)
|
||||
|
||||
const entitlement = buildCommercialMutation('upsertEntitlement', {
|
||||
entitlementKey: 'ocr', metricKey: 'ocr_calls', entitlementType: 'metered', unit: '次',
|
||||
includedQuantity: '100', hardLimitQuantity: '120', resetInterval: 'monthly',
|
||||
overagePolicy: 'block', effectiveFrom: '2026-07-16T09:30', configJson: '{}'
|
||||
}, { subscription: { id: 'sub-1' } })
|
||||
assert.equal(entitlement.subscriptionId, 'sub-1')
|
||||
assert.equal(entitlement.hardLimitQuantity, '120')
|
||||
})
|
||||
|
||||
test('客户端在写入前拒绝不完整窗口、降配和无效 JSON', () => {
|
||||
assert.throws(() => buildCommercialMutation('createPlan', {
|
||||
planCode: 'p', name: 'P', pricingModel: 'subscription', billingInterval: 'annual', currency: 'CNY',
|
||||
baseFee: '0', includedSeats: 1, effectiveFrom: '2026-08-01T00:00', effectiveTo: '2026-07-01T00:00', contractTermsJson: '{}'
|
||||
}), /晚于/u)
|
||||
assert.throws(() => buildCommercialMutation('upsertEntitlement', {
|
||||
entitlementKey: 'ocr', metricKey: 'ocr', entitlementType: 'metered', unit: '次', includedQuantity: '100',
|
||||
hardLimitQuantity: '10', resetInterval: 'monthly', overagePolicy: 'block', effectiveFrom: '2026-07-01T00:00', configJson: '{}'
|
||||
}, { subscription: { id: 'sub-1' } }), /不能小于/u)
|
||||
assert.throws(() => buildCommercialMutation('createPlan', {
|
||||
planCode: 'p', name: 'P', pricingModel: 'subscription', billingInterval: 'annual', currency: 'CNY',
|
||||
baseFee: '0', includedSeats: 1, effectiveFrom: '2026-07-01T00:00', contractTermsJson: '{bad}'
|
||||
}), /有效 JSON/u)
|
||||
})
|
||||
|
||||
test('用量和成本事件在客户端执行幂等、符号、冲回与币种边界校验', () => {
|
||||
const account = { subscription: { id: 'sub-1' } }
|
||||
const usage = buildCommercialMutation('recordUsage', {
|
||||
entitlementId: 'ent-1', eventType: 'usage', quantity: '1', occurredAt: '2026-07-16T09:30',
|
||||
sourceSystem: 'web', idempotencyKey: 'usage-1', metadataJson: '{}'
|
||||
}, account)
|
||||
assert.equal(usage.subscriptionId, 'sub-1')
|
||||
assert.match(usage.occurredAt, /Z$/u)
|
||||
|
||||
assert.throws(() => buildCommercialMutation('recordUsage', {
|
||||
entitlementId: 'ent-1', eventType: 'credit', quantity: '1', occurredAt: '2026-07-16T09:30',
|
||||
sourceSystem: 'web', idempotencyKey: 'usage-2', metadataJson: '{}'
|
||||
}, account), /必须小于 0/u)
|
||||
assert.throws(() => buildCommercialMutation('recordUsage', {
|
||||
entitlementId: 'ent-1', eventType: 'reversal', quantity: '-1', occurredAt: '2026-07-16T09:30',
|
||||
sourceSystem: 'web', idempotencyKey: 'usage-3', metadataJson: '{}'
|
||||
}, account), /被冲回/u)
|
||||
|
||||
const cost = buildCommercialMutation('recordCost', {
|
||||
eventType: 'incurred', costCategory: 'ai_inference', quantity: '3', unit: '次', unitCost: '0.02',
|
||||
originalCurrency: 'usd', reportingCurrency: 'cny', fxRate: '7.2', allocationKey: 'ai-july',
|
||||
occurredAt: '2026-07-16T09:30', sourceSystem: 'billing', idempotencyKey: 'cost-1', metadataJson: '{}'
|
||||
}, account)
|
||||
assert.equal(cost.originalCurrency, 'USD')
|
||||
assert.equal(cost.reportingCurrency, 'CNY')
|
||||
assert.equal(cost.fxRate, '7.2')
|
||||
})
|
||||
|
||||
test('订阅状态变更必须使用版本、受限目标状态和可审计原因', () => {
|
||||
const transition = buildCommercialMutation('transitionSubscription', {
|
||||
resourceId: 'sub-1', expectedVersion: 3, targetStatus: 'suspended', reason: '客户合同暂停'
|
||||
})
|
||||
assert.deepEqual(transition, {
|
||||
resourceId: 'sub-1', expectedVersion: 3, targetStatus: 'suspended', reason: '客户合同暂停'
|
||||
})
|
||||
assert.throws(() => buildCommercialMutation('transitionSubscription', {
|
||||
resourceId: 'sub-1', expectedVersion: 3, targetStatus: 'active', reason: '恢复'
|
||||
}), /目标订阅状态无效/u)
|
||||
assert.throws(() => buildCommercialMutation('transitionSubscription', {
|
||||
resourceId: 'sub-1', expectedVersion: 3, targetStatus: 'canceled', reason: 'x'
|
||||
}), /至少填写 2/u)
|
||||
})
|
||||
|
||||
function metric(key, label, values, status = 'available') {
|
||||
return { key, label, status, values, ratios: [], reason: '真实事实口径', requiredInputs: [], notes: [] }
|
||||
}
|
||||
|
||||
function unavailable(key, label) {
|
||||
return { key, label, status: 'unavailable', values: [], ratios: [], reason: '缺少真实财务证据', requiredInputs: ['真实财务证据'], notes: [] }
|
||||
}
|
||||
@@ -9,6 +9,10 @@ const topBar = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/layout/TopBar.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const topBarOverviewRange = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/layout/useTopBarOverviewRange.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const overviewView = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/OverviewView.vue', import.meta.url)),
|
||||
'utf8'
|
||||
@@ -62,7 +66,10 @@ test('digital employee dashboard normalizes backend payload fields', () => {
|
||||
})
|
||||
|
||||
test('digital employee dashboard is wired into overview dashboard switch', () => {
|
||||
assert.match(topBar, /label: '数字员工看板', value: 'digitalEmployee'/)
|
||||
assert.match(topBar, /import \{ useTopBarOverviewRange \} from '\.\/useTopBarOverviewRange\.js'/)
|
||||
assert.match(topBar, /:options="overviewDashboardOptions"/)
|
||||
assert.match(topBar, /useTopBarOverviewRange\(props, emit\)/)
|
||||
assert.match(topBarOverviewRange, /label: '数字员工看板', value: 'digitalEmployee'/)
|
||||
assert.match(overviewView, /<DigitalEmployeeDashboard/)
|
||||
assert.match(overviewView, /activeDashboard === 'digitalEmployee'/)
|
||||
assert.match(overviewView, /digitalEmployeeKpiMetrics/)
|
||||
|
||||
@@ -149,10 +149,10 @@ const flowScript = [
|
||||
'../src/views/scripts/travelReimbursementFlowToolModel.js',
|
||||
'../src/views/scripts/travelReimbursementFlowTiming.js'
|
||||
].map((path) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')).join('\n')
|
||||
const personalWorkbenchAiModeScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const personalWorkbenchAiModeScript = [
|
||||
'../src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js',
|
||||
'../src/composables/workbenchAiMode/useWorkbenchAiIntentExecution.js'
|
||||
].map((path) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')).join('\n')
|
||||
const applicationPreviewFlowScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js', import.meta.url)),
|
||||
'utf8'
|
||||
@@ -276,7 +276,7 @@ test('AI workbench routes compact travel direct-submit planner into preview with
|
||||
)
|
||||
assert.match(
|
||||
personalWorkbenchAiModeScript,
|
||||
/const rulePlan = buildRuleFallbackWorkbenchAiIntentPlan\(cleanPrompt\)/
|
||||
/resolveExecutableTravelApplicationPlan\(\s*buildRuleFallbackWorkbenchAiIntentPlan\(cleanPrompt\)\s*\)/
|
||||
)
|
||||
assert.match(
|
||||
personalWorkbenchAiModeScript,
|
||||
|
||||
64
web/tests/financial-connector-health-panel.test.mjs
Normal file
64
web/tests/financial-connector-health-panel.test.mjs
Normal file
@@ -0,0 +1,64 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { compileScript, compileTemplate, parse } from '@vue/compiler-sfc'
|
||||
|
||||
import {
|
||||
fetchFinancialConnectorObservability,
|
||||
fetchFinancialPaymentEvidence
|
||||
} from '../src/services/financialConnectors.js'
|
||||
|
||||
const panelPath = fileURLToPath(new URL('../src/components/dashboard/FinancialConnectorHealthPanel.vue', import.meta.url))
|
||||
const panelSource = readFileSync(panelPath, 'utf8')
|
||||
const overviewSource = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/OverviewView.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('连接器观测与付款证据接口使用租户内路径并限制窗口', async () => {
|
||||
const requests = []
|
||||
const originalFetch = globalThis.fetch
|
||||
globalThis.fetch = async (url) => {
|
||||
requests.push(String(url))
|
||||
return new Response(JSON.stringify({ summary: {}, items: [] }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
})
|
||||
}
|
||||
try {
|
||||
await fetchFinancialConnectorObservability({ windowHours: 999 })
|
||||
await fetchFinancialPaymentEvidence('claim/a')
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
|
||||
assert.equal(requests[0], '/api/v1/financial-connectors/observability?window_hours=720')
|
||||
assert.equal(requests[1], '/api/v1/financial-connectors/payment-evidence/claim%2Fa')
|
||||
await assert.rejects(() => fetchFinancialPaymentEvidence(''), /不能为空/u)
|
||||
})
|
||||
|
||||
test('连接器健康面板可以独立编译并接入财务看板', () => {
|
||||
const parsed = parse(panelSource, { filename: panelPath })
|
||||
assert.deepEqual(parsed.errors, [])
|
||||
const script = compileScript(parsed.descriptor, { id: 'financial-connector-health-panel' })
|
||||
const template = compileTemplate({
|
||||
source: parsed.descriptor.template?.content || '',
|
||||
filename: panelPath,
|
||||
id: 'financial-connector-health-panel',
|
||||
compilerOptions: { bindingMetadata: script.bindings }
|
||||
})
|
||||
assert.deepEqual(template.errors, [])
|
||||
assert.match(overviewSource, /<FinancialConnectorHealthPanel/u)
|
||||
assert.match(overviewSource, /activeDashboard === 'finance'/u)
|
||||
})
|
||||
|
||||
test('面板明确区分证据等级且不把未采集运行指标伪装成零', () => {
|
||||
assert.match(panelSource, /生产外部回执 · 高可信现金事实/u)
|
||||
assert.match(panelSource, /人工付款确认 · 低等级内部状态/u)
|
||||
assert.match(panelSource, /模拟\/预发布回执 · 仅验证,不入核心账/u)
|
||||
assert.match(panelSource, /retry_count == null \? '待耐久遥测'/u)
|
||||
assert.match(panelSource, /signature_failure_count == null \? '待耐久遥测'/u)
|
||||
assert.doesNotMatch(panelSource, /raw_payload|signature_header|secret_ref/u)
|
||||
})
|
||||
@@ -13,7 +13,7 @@ function testIngestTimeUsesDedicatedColumn() {
|
||||
)
|
||||
assert.match(
|
||||
policiesView,
|
||||
/<td>\s*<div class="state-cell">\s*<span class="state-tag"[^>]*>\{\{ doc\.state \}\}<\/span>\s*<\/div>\s*<\/td>\s*<td class="ingest-time-cell">\{\{ doc\.ingestTime \|\| '—' \}\}<\/td>/,
|
||||
/<td data-label="状态">\s*<div class="state-cell">\s*<span class="state-tag"[^>]*>\{\{ doc\.state \}\}<\/span>\s*<\/div>\s*<\/td>\s*<td class="ingest-time-cell" data-label="归纳时间">\{\{ doc\.ingestTime \|\| '—' \}\}<\/td>/,
|
||||
'状态列只展示状态标签,归纳时间需要放到独立单元格'
|
||||
)
|
||||
assert.doesNotMatch(
|
||||
|
||||
@@ -10,6 +10,7 @@ function readProjectFile(path) {
|
||||
|
||||
function testReceiptFolderViewSurface() {
|
||||
const view = readProjectFile('web/src/views/ReceiptFolderView.vue')
|
||||
const formatting = readProjectFile('web/src/views/scripts/receiptFolderFormatting.js')
|
||||
|
||||
assert.match(view, /activeStatus = ref\('all'\)/)
|
||||
assert.match(view, /value: 'all'/)
|
||||
@@ -32,7 +33,9 @@ function testReceiptFolderViewSurface() {
|
||||
assert.match(view, /receiptEditLogs/)
|
||||
assert.match(view, /previewFrameUrl/)
|
||||
assert.match(view, /previewTransform/)
|
||||
assert.match(view, /String\(value \?\? ''\)\.trim\(\)/)
|
||||
assert.match(view, /formatReceiptDateTime as formatDateTime/)
|
||||
assert.match(view, /formatReceiptRecognitionScore as formatScore/)
|
||||
assert.match(formatting, /String\(value \?\? ''\)\.trim\(\)/)
|
||||
assert.match(view, /openAssociateDialogForCurrentReceipt/)
|
||||
assert.match(view, /createReceiptDetailDashboardModel/)
|
||||
assert.match(view, /createReceiptDetailFieldModel/)
|
||||
|
||||
@@ -40,6 +40,10 @@ const topBarComponent = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/layout/TopBar.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const topBarOverviewRange = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/layout/useTopBarOverviewRange.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const appShellComposable = readFileSync(
|
||||
fileURLToPath(new URL('../src/composables/useAppShell.js', import.meta.url)),
|
||||
'utf8'
|
||||
@@ -144,10 +148,13 @@ test('risk dashboard follows the top overview range without card-level selectors
|
||||
|
||||
test('overview custom date defaults use current year instead of hard-coded legacy dates', () => {
|
||||
assert.match(topBarComponent, /createCurrentYearDateRange/)
|
||||
assert.match(topBarComponent, /formatDateValue/)
|
||||
assert.match(topBarComponent, /useTopBarOverviewRange/)
|
||||
assert.match(topBarOverviewRange, /formatDateValue/)
|
||||
assert.match(topBarOverviewRange, /useTopBarOverviewRange\(props, emit\)/)
|
||||
assert.match(appShellComposable, /createCurrentYearDateRange\(\)/)
|
||||
assert.match(legacyAppScript, /createCurrentYearDateRange\(\)/)
|
||||
assert.doesNotMatch(topBarComponent, /2024-07-06|2024-07-12/)
|
||||
assert.doesNotMatch(topBarOverviewRange, /2024-07-06|2024-07-12/)
|
||||
assert.doesNotMatch(appShellComposable, /2024-07-06|2024-07-12/)
|
||||
assert.doesNotMatch(legacyAppScript, /2024-07-06|2024-07-12/)
|
||||
})
|
||||
|
||||
@@ -7,10 +7,23 @@ const detailTemplate = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/TravelRequestDetailView.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const detailScript = readFileSync(
|
||||
const detailEntryScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/TravelRequestDetailView.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const detailSetupScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/travelRequestDetailSetup.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const detailApprovalFlowScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelRequestDetailApprovalFlow.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const detailRiskSubmitScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelRequestDetailRiskSubmit.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const detailScript = [detailSetupScript, detailApprovalFlowScript, detailRiskSubmitScript].join('\n')
|
||||
const detailStyles = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/views/travel-request-detail-view.css', import.meta.url)),
|
||||
'utf8'
|
||||
@@ -60,7 +73,12 @@ function extractFunction(source, name) {
|
||||
}
|
||||
|
||||
test('approval-mode detail collects leader opinion inside confirm dialog before API call', () => {
|
||||
assert.match(detailScript, /approvalMode:/)
|
||||
assert.match(detailEntryScript, /import \{ useTravelRequestDetailSetup \} from '\.\/travelRequestDetailSetup\.js'/)
|
||||
assert.match(detailEntryScript, /setup: useTravelRequestDetailSetup/)
|
||||
assert.match(detailSetupScript, /import \{ useTravelRequestDetailApprovalFlow \} from '\.\/useTravelRequestDetailApprovalFlow\.js'/)
|
||||
assert.match(detailSetupScript, /useTravelRequestDetailApprovalFlow\(\{/)
|
||||
assert.match(detailSetupScript, /approvalRiskConfirmItems: riskSubmit\.approvalRiskConfirmItems/)
|
||||
assert.match(detailEntryScript, /approvalMode:/)
|
||||
assert.match(detailScript, /const leaderOpinion = ref\(''\)/)
|
||||
assert.match(detailScript, /const approveConfirmDialogOpen = ref\(false\)/)
|
||||
assert.match(detailScript, /const approvalRiskConfirmed = ref\(false\)/)
|
||||
|
||||
@@ -15,6 +15,10 @@ const responsiveStyles = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/views/travel-request-detail-view-part2.css', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const applicationFactsStyles = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/components/travel-request-application-facts.css', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const detailScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/TravelRequestDetailView.js', import.meta.url)),
|
||||
'utf8'
|
||||
@@ -25,8 +29,8 @@ test('detail hero facts keep document number and date on one row on laptop scree
|
||||
assert.match(detailStyles, /\.hero-fact-grid \{[\s\S]*grid-template-columns:\s*minmax\(240px,\s*1\.25fr\) repeat\(3,\s*minmax\(0,\s*1fr\)\)/)
|
||||
assert.match(responsiveStyles, /@media \(max-width:\s*1320px\) \{[\s\S]*\.hero-fact-grid \{[\s\S]*grid-template-columns:\s*minmax\(280px,\s*1\.4fr\) repeat\(3,\s*minmax\(0,\s*1fr\)\)/)
|
||||
assert.match(responsiveStyles, /@media \(max-width:\s*1320px\) \{[\s\S]*\.hero-fact strong \{[\s\S]*white-space:\s*nowrap/)
|
||||
assert.match(detailStyles, /\.application-detail-facts \{[\s\S]*grid-template-columns:\s*repeat\(2,\s*minmax\(0,\s*1fr\)\)/)
|
||||
assert.match(detailStyles, /\.application-detail-fact \{[\s\S]*grid-template-columns:\s*minmax\(96px,\s*28%\) minmax\(0,\s*1fr\)/)
|
||||
assert.match(applicationFactsStyles, /\.application-detail-facts \{[\s\S]*grid-template-columns:\s*repeat\(2,\s*minmax\(0,\s*1fr\)\)/)
|
||||
assert.match(applicationFactsStyles, /\.application-detail-fact \{[\s\S]*grid-template-columns:\s*minmax\(96px,\s*28%\) minmax\(0,\s*1fr\)/)
|
||||
assert.doesNotMatch(detailScript, /key:\s*'status'[\s\S]*label:\s*'当前状态'/)
|
||||
})
|
||||
|
||||
|
||||
@@ -127,6 +127,14 @@ const relatedApplicationCardTemplate = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/travel/TravelRequestRelatedApplicationCard.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const applicationFactsTemplate = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/travel/TravelRequestApplicationFacts.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const applicationFactsStyle = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/components/travel-request-application-facts.css', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const progressCardTemplate = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/travel/TravelRequestProgressCard.vue', import.meta.url)),
|
||||
'utf8'
|
||||
@@ -1583,10 +1591,12 @@ test('application detail uses application labels instead of reimbursement labels
|
||||
assert.match(progressCardTemplate, /isApplicationDocument \? '申请进度'/)
|
||||
assert.match(detailViewTemplate, /isApplicationDocument \? '申请详情' : '费用明细'/)
|
||||
assert.match(detailViewTemplate, /展示本次申请的事实信息、职级规则测算和用户预估费用/)
|
||||
assert.match(detailViewTemplate, /class="application-detail-facts"/)
|
||||
assert.match(detailViewTemplate, /applicationDetailFactItems/)
|
||||
assert.match(detailViewTemplate, /<TravelRequestApplicationFacts/)
|
||||
assert.match(detailViewTemplate, /:fact-items="applicationDetailFactItems"/)
|
||||
assert.match(applicationFactsTemplate, /class="application-detail-facts"/)
|
||||
assert.match(applicationFactsTemplate, /v-for="item in factItems"/)
|
||||
assert.match(detailViewScript, /buildApplicationDetailFactItems/)
|
||||
assert.match(detailViewStyle, /\.application-detail-fact\.highlight strong/)
|
||||
assert.match(applicationFactsStyle, /\.application-detail-fact\.highlight strong/)
|
||||
assert.match(detailViewTemplate, /isApplicationDocument \? '申请类型' : '报销类型'/)
|
||||
assert.match(detailViewTemplate, /isApplicationDocument \? '预计金额' : '报销金额'/)
|
||||
assert.match(detailViewTemplate, /isApplicationDocument \? '退回申请' : '退回单据'/)
|
||||
@@ -1595,10 +1605,13 @@ test('application detail uses application labels instead of reimbursement labels
|
||||
|
||||
test('draft or returned application detail edits allowed facts inline', () => {
|
||||
assert.doesNotMatch(detailViewTemplate, /修改申请/)
|
||||
assert.match(detailViewTemplate, /canEditApplicationDetailItem\(item\)/)
|
||||
assert.match(detailViewTemplate, /application-detail-edit-btn/)
|
||||
assert.match(detailViewTemplate, /openApplicationDetailEditor\(item\)/)
|
||||
assert.match(detailViewTemplate, /saveApplicationDetailEdit\(item\)/)
|
||||
assert.match(detailViewTemplate, /:can-edit-item="canEditApplicationDetailItem"/)
|
||||
assert.match(detailViewTemplate, /@open="openApplicationDetailEditor"/)
|
||||
assert.match(detailViewTemplate, /@save="saveApplicationDetailEdit"/)
|
||||
assert.match(applicationFactsTemplate, /canEditItem\(item\)/)
|
||||
assert.match(applicationFactsTemplate, /application-detail-edit-btn/)
|
||||
assert.match(applicationFactsTemplate, /emit\('open', item\)/)
|
||||
assert.match(applicationFactsTemplate, /emit\('save', item\)/)
|
||||
assert.doesNotMatch(detailViewScript, /handleModifyApplication/)
|
||||
assert.match(
|
||||
detailViewScript,
|
||||
|
||||
@@ -16,7 +16,10 @@ const composerComponent = readSource('../src/components/business/workbench-ai/Wo
|
||||
const fileStripComponent = readSource('../src/components/business/workbench-ai/WorkbenchAiFileStrip.vue')
|
||||
const filePreviewComponent = readSource('../src/components/business/workbench-ai/WorkbenchAiFilePreviewDialog.vue')
|
||||
const filePreviewStyles = readSource('../src/assets/styles/components/workbench-ai-file-preview-dialog.css')
|
||||
const aiModeRuntime = readSource('../src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js')
|
||||
const aiModeRuntime = [
|
||||
readSource('../src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js'),
|
||||
readSource('../src/composables/workbenchAiMode/useWorkbenchAiConversationRuntime.js')
|
||||
].join('\n')
|
||||
const filePreviewRuntime = readSource('../src/composables/workbenchAiMode/useWorkbenchAiFilePreview.js')
|
||||
|
||||
function countOccurrences(source, pattern) {
|
||||
|
||||
@@ -20,10 +20,10 @@ import {
|
||||
import { buildInlineApplicationPreview } from '../src/composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js'
|
||||
import { createWorkbenchAiMessageRuntime } from '../src/composables/workbenchAiMode/workbenchAiMessageModel.js'
|
||||
|
||||
const personalWorkbenchAiModeScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const personalWorkbenchAiModeScript = [
|
||||
'../src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js',
|
||||
'../src/composables/workbenchAiMode/useWorkbenchAiIntentExecution.js'
|
||||
].map((path) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')).join('\n')
|
||||
const stewardFlowScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/composables/workbenchAiMode/useWorkbenchAiStewardFlow.js', import.meta.url)),
|
||||
'utf8'
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { readFileSync, readdirSync } 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'
|
||||
import { normalizeInlineApplicationStatusLabel } from '../src/composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js'
|
||||
|
||||
const aiMode = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/business/PersonalWorkbenchAiMode.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
function readSource(path) {
|
||||
return readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
|
||||
}
|
||||
|
||||
const aiModeRuntimeDir = fileURLToPath(new URL('../src/composables/workbenchAiMode/', import.meta.url))
|
||||
const applicationPreviewFlow = readSource('../src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js')
|
||||
const applicationPreviewModel = readSource('../src/composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js')
|
||||
const aiMode = [
|
||||
readSource('../src/components/business/PersonalWorkbenchAiMode.vue'),
|
||||
readSource('../src/components/business/PersonalWorkbenchAiMode.template.html'),
|
||||
readSource('../src/utils/aiDocumentQueryModel.js'),
|
||||
...readdirSync(aiModeRuntimeDir)
|
||||
.filter((file) => file.endsWith('.js'))
|
||||
.sort()
|
||||
.map((file) => readSource(`../src/composables/workbenchAiMode/${file}`))
|
||||
].join('\n')
|
||||
|
||||
test('expense scene selection message asks for type first and mentions application gate', () => {
|
||||
const text = buildExpenseSceneSelectionMessage('帮我发起一笔报销,并检查需要准备哪些票据材料。')
|
||||
@@ -32,10 +45,10 @@ test('expense scene actions mark travel and meal as requiring application', () =
|
||||
assert.equal(transport.payload.next_session_type, 'expense')
|
||||
})
|
||||
|
||||
test('AI mode quick reimbursement card opens scene selection before steward plan', () => {
|
||||
test('AI mode quick reimbursement card enters the reimbursement association gate before steward plan', () => {
|
||||
assert.match(
|
||||
aiMode,
|
||||
/function runAiModeAction\(item\) {[\s\S]{0,220}pushInlineExpenseSceneSelectionPrompt\(item\.prompt, item\.label\)/
|
||||
/function runAiModeAction\(item\) {[\s\S]{0,300}expenseFlow\.startAiReimbursementAssociationGate\(item\.prompt, item\.label\)/
|
||||
)
|
||||
})
|
||||
|
||||
@@ -53,27 +66,28 @@ test('AI mode offers an inline application shortcut when no candidate applicatio
|
||||
assert.match(aiMode, /buildLocalApplicationPreviewMessage/)
|
||||
assert.match(aiMode, /refreshApplicationPreviewEstimate/)
|
||||
assert.match(aiMode, /applicationPreview:\s*preview/)
|
||||
assert.match(aiMode, /suggestedActions:\s*buildInlineApplicationPreviewSuggestedActions\(preview\)/)
|
||||
assert.match(aiMode, /suggestedActions:\s*buildInlineApplicationPreviewSuggestedActions\(registeredPreview\)/)
|
||||
assert.doesNotMatch(aiMode, /function startAiApplicationDraft/)
|
||||
assert.doesNotMatch(aiMode, /buildAiApplicationStepPrompt/)
|
||||
})
|
||||
|
||||
test('AI mode steward reimbursement action opens expense scene selection locally', () => {
|
||||
test('AI mode steward reimbursement action enters the association gate while standalone flow keeps scene selection', () => {
|
||||
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, /action\?\.payload\?\.session_type[\s\S]*SESSION_TYPE_EXPENSE/)
|
||||
assert.match(aiMode, /expenseFlow\.startAiReimbursementAssociationGate\(carryText, action\.label\)/)
|
||||
assert.match(
|
||||
aiMode,
|
||||
/SESSION_TYPE_EXPENSE[\s\S]{0,140}pushInlineExpenseSceneSelectionPrompt\(carryText, action\.label\)[\s\S]{0,40}return/
|
||||
/SESSION_TYPE_EXPENSE && carryText === '我要报销'[\s\S]{0,160}expenseFlow\.startAiReimbursementAssociationGate\(carryText, action\.label\)[\s\S]{0,40}return/
|
||||
)
|
||||
assert.match(aiMode, /SKIP_REQUIRED_APPLICATION_LINK_ACTION[\s\S]{0,180}pushInlineExpenseSceneSelectionPrompt/)
|
||||
})
|
||||
|
||||
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, /fetchExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
|
||||
assert.match(aiMode, /filterRequiredApplicationCandidates\(claims, 'travel', currentUser\.value \|\| \{\}\)/)
|
||||
assert.match(aiMode, /required_application_gate/)
|
||||
assert.match(aiMode, /await attachAiRequiredApplicationGate\(planRequest, prompt\)/)
|
||||
@@ -95,7 +109,8 @@ test('AI mode handles document query prompts locally before steward planning', (
|
||||
assert.match(aiMode, /查询业务单据接口/)
|
||||
assert.match(aiMode, /组合筛选单据/)
|
||||
assert.match(aiMode, /if \(await handleAiDocumentQueryIntent\(prompt, pendingMessage\)\) \{[\s\S]*return[\s\S]*\}/)
|
||||
assert.match(aiMode, /emit\('open-document', buildAiDocumentDetailRequest\(detailReference\)\)/)
|
||||
assert.match(aiMode, /await ensureAiDocumentDetailStillAvailable\(detailRequest\)/)
|
||||
assert.match(aiMode, /emit\('open-document', detailRequest\)/)
|
||||
})
|
||||
|
||||
test('AI mode asks for manual confirmation before generating application preview table', () => {
|
||||
@@ -114,8 +129,8 @@ test('AI mode asks for manual confirmation before generating application preview
|
||||
})
|
||||
|
||||
test('AI mode shows pending feedback before async application preview estimate refresh', () => {
|
||||
const startPreviewFunction = aiMode.match(
|
||||
/async function startAiApplicationPreview[\s\S]*?\n}\n\nfunction requestDeleteCurrentConversation/
|
||||
const startPreviewFunction = applicationPreviewFlow.match(
|
||||
/async function startAiApplicationPreview[\s\S]*?\n }\n\n return \{/
|
||||
)?.[0] || ''
|
||||
|
||||
assert.match(startPreviewFunction, /const pendingMessage = createInlineMessage\(\s*'assistant',\s*'正在生成申请核对表/)
|
||||
@@ -142,8 +157,8 @@ test('AI mode handles application preview save and submit through buttons or tex
|
||||
assert.match(aiMode, /function executeInlineApplicationPreviewAction\(actionType, sourceMessage = null, options = \{\}\)/)
|
||||
assert.match(aiMode, /function confirmInlineApplicationSubmit\(\)/)
|
||||
assert.match(aiMode, /function cancelInlineApplicationSubmitConfirm\(\)/)
|
||||
assert.match(aiMode, /function handleInlineApplicationPreviewTextAction\(prompt\)/)
|
||||
assert.match(aiMode, /if \(handleInlineApplicationPreviewTextAction\(cleanPrompt\)\) \{[\s\S]*return[\s\S]*\}/)
|
||||
assert.match(aiMode, /function handleInlineApplicationPreviewTextAction\(prompt, applicationPreviewEstimatePending\)/)
|
||||
assert.match(aiMode, /if \(applicationFlow\.handleInlineApplicationPreviewTextAction\(cleanPrompt, applicationPreviewEstimatePending\)\) \{[\s\S]*return[\s\S]*\}/)
|
||||
assert.match(aiMode, /\[AI_APPLICATION_ACTION_SAVE_DRAFT, AI_APPLICATION_ACTION_SUBMIT\]\.includes\(actionType\)/)
|
||||
assert.match(aiMode, /normalizedPreview\.readyToSubmit/)
|
||||
assert.match(aiMode, /fetchExpenseClaims\(\{ page: 1, pageSize: 100 \}\)/)
|
||||
@@ -155,22 +170,23 @@ test('AI mode handles application preview save and submit through buttons or tex
|
||||
test('AI mode keeps missing application fields editable in the preview table without quick template action', () => {
|
||||
assert.match(aiMode, /function buildInlineApplicationPreviewSuggestedActions\(applicationPreview = \{\}, draftPayload = null\)/)
|
||||
assert.match(aiMode, /label:\s*'保存草稿'/)
|
||||
assert.match(aiMode, /function handleInlineApplicationPreviewTextAction\(prompt\)/)
|
||||
assert.match(aiMode, /function handleInlineApplicationPreviewTextAction\(prompt, applicationPreviewEstimatePending\)/)
|
||||
assert.doesNotMatch(aiMode, /label:\s*'快速模板'/)
|
||||
assert.doesNotMatch(aiMode, /action_type:\s*'prefill_composer'/)
|
||||
assert.doesNotMatch(aiMode, /buildInlineApplicationPreviewTemplatePrefill/)
|
||||
assert.doesNotMatch(aiMode, /applyInlineApplicationPreviewTemplateText/)
|
||||
})
|
||||
|
||||
test('AI mode waits for submit confirmation before adding submit action to the conversation', () => {
|
||||
const executeStart = aiMode.indexOf('async function executeInlineApplicationPreviewAction')
|
||||
const executeEnd = aiMode.indexOf('\nfunction handleInlineApplicationPreviewTextAction', executeStart)
|
||||
const executeBlock = aiMode.slice(executeStart, executeEnd)
|
||||
const confirmGateIndex = executeBlock.indexOf('if (isSubmit && !options.confirmed)')
|
||||
test('AI mode confirms a new direct submit before adding the action while allowing contextual saved-draft submit', () => {
|
||||
const executeStart = applicationPreviewFlow.indexOf('async function executeInlineApplicationPreviewAction')
|
||||
const executeEnd = applicationPreviewFlow.indexOf('\n function handleInlineApplicationPreviewTextAction', executeStart)
|
||||
const executeBlock = applicationPreviewFlow.slice(executeStart, executeEnd)
|
||||
const confirmGateIndex = executeBlock.indexOf('if (isSubmit && !options.confirmed && !shouldSubmitSavedDraftDirectly)')
|
||||
const requestConfirmIndex = executeBlock.indexOf('requestInlineApplicationSubmitConfirmation', confirmGateIndex)
|
||||
const confirmedActionPushIndex = executeBlock.indexOf('pushInlineApplicationActionUserMessage(userText)', requestConfirmIndex)
|
||||
|
||||
assert.ok(confirmGateIndex >= 0, '直接提交应先进入确认分支')
|
||||
assert.match(executeBlock, /const shouldSubmitSavedDraftDirectly = isSubmit/)
|
||||
assert.ok(confirmGateIndex >= 0, '非上下文草稿提交应先进入确认分支')
|
||||
assert.ok(requestConfirmIndex > confirmGateIndex, '直接提交确认分支应先打开确认弹窗')
|
||||
assert.ok(confirmedActionPushIndex > requestConfirmIndex, '确认弹窗打开前不应追加“直接提交”用户消息')
|
||||
assert.match(
|
||||
@@ -178,22 +194,22 @@ test('AI mode waits for submit confirmation before adding submit action to the c
|
||||
/requestInlineApplicationSubmitConfirmation\(targetMessage,\s*\{\s*\.\.\.options,\s*userText\s*\}\)/
|
||||
)
|
||||
|
||||
const confirmStart = aiMode.indexOf('function confirmInlineApplicationSubmit()')
|
||||
const confirmEnd = aiMode.indexOf('\nasync function runInlineApplicationSubmitPrecheck', confirmStart)
|
||||
const confirmBlock = aiMode.slice(confirmStart, confirmEnd)
|
||||
const confirmStart = applicationPreviewFlow.indexOf('function confirmInlineApplicationSubmit()')
|
||||
const confirmEnd = applicationPreviewFlow.indexOf('\n async function runInlineApplicationSubmitPrecheck', confirmStart)
|
||||
const confirmBlock = applicationPreviewFlow.slice(confirmStart, confirmEnd)
|
||||
assert.match(confirmBlock, /userText:\s*context\.userText \|\| '直接提交'/)
|
||||
assert.match(confirmBlock, /skipUserMessage:\s*false/)
|
||||
|
||||
const cancelStart = aiMode.indexOf('function cancelInlineApplicationSubmitConfirm()')
|
||||
const cancelEnd = aiMode.indexOf('\nfunction confirmInlineApplicationSubmit', cancelStart)
|
||||
const cancelBlock = aiMode.slice(cancelStart, cancelEnd)
|
||||
const cancelStart = applicationPreviewFlow.indexOf('function cancelInlineApplicationSubmitConfirm()')
|
||||
const cancelEnd = applicationPreviewFlow.indexOf('\n function confirmInlineApplicationSubmit', cancelStart)
|
||||
const cancelBlock = applicationPreviewFlow.slice(cancelStart, cancelEnd)
|
||||
assert.doesNotMatch(cancelBlock, /pushInlineUserMessage|pushInlineApplicationActionUserMessage/)
|
||||
})
|
||||
|
||||
test('AI mode formats saved application draft as a detail table without continuing submit flow', () => {
|
||||
assert.match(aiMode, /function buildInlineApplicationResultTable\(draftPayload = \{\}, options = \{\}\)/)
|
||||
assert.match(aiMode, /function normalizeInlineApplicationStatusLabel\(value, fallback = ''\)/)
|
||||
assert.match(aiMode, /submitted:\s*'审批中'/)
|
||||
assert.equal(normalizeInlineApplicationStatusLabel('submitted'), '审批中')
|
||||
assert.match(aiMode, /const statusLabel = normalizeInlineApplicationStatusLabel\(info\.statusLabel, options\.statusLabel\)/)
|
||||
assert.match(aiMode, /\| 单据类型 \| 单据编号 \| 单据状态 \| 当前节点 \| 日期 \| 地点 \| 事由 \| 金额 \|/)
|
||||
assert.doesNotMatch(aiMode, /\| 单据类型 \| 单据编号 \| 单据状态 \| 当前节点 \| 日期 \| 地点 \| 事由 \| 金额 \| 操作 \|/)
|
||||
@@ -204,9 +220,9 @@ test('AI mode formats saved application draft as a detail table without continui
|
||||
assert.match(aiMode, /function buildInlineApplicationDetailAction\(draftPayload = \{\}\)/)
|
||||
assert.match(aiMode, /action_type:\s*'open_application_detail'/)
|
||||
|
||||
const resultStart = aiMode.indexOf('function buildInlineApplicationPreviewActionResultText')
|
||||
const resultEnd = aiMode.indexOf('\nfunction buildInlineApplicationDetailAction', resultStart)
|
||||
const resultBlock = aiMode.slice(resultStart, resultEnd)
|
||||
const resultStart = applicationPreviewModel.indexOf('function buildInlineApplicationPreviewActionResultText')
|
||||
const resultEnd = applicationPreviewModel.indexOf('\nexport function buildInlineApplicationDetailAction', resultStart)
|
||||
const resultBlock = applicationPreviewModel.slice(resultStart, resultEnd)
|
||||
const submitBranchIndex = resultBlock.indexOf('actionType === AI_APPLICATION_ACTION_SUBMIT')
|
||||
const saveBranchIndex = resultBlock.indexOf("'### 申请草稿已保存'")
|
||||
const saveBranch = resultBlock.slice(saveBranchIndex)
|
||||
@@ -219,15 +235,16 @@ test('AI mode formats saved application draft as a detail table without continui
|
||||
)
|
||||
assert.doesNotMatch(saveBranch, /进入审批流程/)
|
||||
|
||||
const executeStart = aiMode.indexOf('async function executeInlineApplicationPreviewAction')
|
||||
const executeEnd = aiMode.indexOf('\nfunction handleInlineApplicationPreviewTextAction', executeStart)
|
||||
const executeBlock = aiMode.slice(executeStart, executeEnd)
|
||||
const executeStart = applicationPreviewFlow.indexOf('async function executeInlineApplicationPreviewAction')
|
||||
const executeEnd = applicationPreviewFlow.indexOf('\n function handleInlineApplicationPreviewTextAction', executeStart)
|
||||
const executeBlock = applicationPreviewFlow.slice(executeStart, executeEnd)
|
||||
assert.match(executeBlock, /targetMessage\.suggestedActions = \[\]/)
|
||||
assert.doesNotMatch(
|
||||
executeBlock,
|
||||
/targetMessage\.suggestedActions = isSubmit[\s\S]*buildInlineApplicationPreviewSuggestedActions\(targetMessage\.applicationPreview, draftPayload\)/
|
||||
)
|
||||
assert.match(executeBlock, /suggestedActions:\s*buildInlineApplicationDetailAction\(draftPayload\)/)
|
||||
assert.match(executeBlock, /const detailActions = buildInlineApplicationDetailAction\(draftPayload\)/)
|
||||
assert.match(executeBlock, /suggestedActions:\s*shouldAutoContinueNextTask[\s\S]*detailActions/)
|
||||
})
|
||||
|
||||
test('AI mode locks application preview actions while estimate refresh is pending', () => {
|
||||
@@ -236,13 +253,13 @@ test('AI mode locks application preview actions while estimate refresh is pendin
|
||||
aiMode,
|
||||
/function buildInlineApplicationPreviewSuggestedActions\(applicationPreview = \{\}, draftPayload = null\) \{[\s\S]*if \(isApplicationPreviewEstimatePendingPreview\(applicationPreview\)\) \{[\s\S]*return \[\]/
|
||||
)
|
||||
assert.match(aiMode, /const isAiModeInputLocked = computed\(\(\) => applicationPreviewEstimatePending\.value\)/)
|
||||
assert.match(aiMode, /const isAiModeInputLocked = computed\(\(\) => applicationPreviewEstimatePending\.value \|\| isAiModeReceiptRecognitionPending\.value\)/)
|
||||
assert.match(aiMode, /:disabled="isAiModeInputLocked"/)
|
||||
assert.match(aiMode, /v-if="canShowInlineSuggestedActions\(message\)"/)
|
||||
assert.match(aiMode, /:disabled="isInlineSuggestedActionDisabled\(action, message\)"/)
|
||||
assert.match(
|
||||
aiMode,
|
||||
/message\.suggestedActions = \[\][\s\S]*const committed = await commitApplicationPreviewEditor\(message\)/
|
||||
/message\.suggestedActions = \[\][\s\S]*const committed = await commitBaseApplicationPreviewEditor\(message\)/
|
||||
)
|
||||
assert.match(
|
||||
aiMode,
|
||||
|
||||
@@ -28,128 +28,130 @@ function countGifFrameBlocks(buffer) {
|
||||
return count
|
||||
}
|
||||
|
||||
function readAssetFrames(assetPath, options = {}) {
|
||||
const metadata = execFileSync('identify', ['-format', '%w %h %n\n', assetPath], {
|
||||
encoding: 'utf8'
|
||||
}).trim().split('\n')[0].split(/\s+/).map(Number)
|
||||
const sourceWidth = metadata[0]
|
||||
const sourceHeight = metadata[1]
|
||||
const frameCount = metadata[2] || 1
|
||||
const width = options.width || sourceWidth
|
||||
const height = options.height || sourceHeight
|
||||
const convertArgs = [assetPath, '-coalesce']
|
||||
if (options.width || options.height) {
|
||||
convertArgs.push('-resize', `${width}x${height}!`)
|
||||
}
|
||||
convertArgs.push('-alpha', 'off', '-depth', '8', 'rgb:-')
|
||||
const pixels = execFileSync('convert', convertArgs, {
|
||||
maxBuffer: 64 * 1024 * 1024
|
||||
})
|
||||
const frameSize = width * height * 3
|
||||
return {
|
||||
frameCount: Math.min(frameCount, Math.floor(pixels.length / frameSize)),
|
||||
frameSize,
|
||||
height,
|
||||
pixels,
|
||||
width
|
||||
}
|
||||
}
|
||||
|
||||
function measureGifMotion(assetPath) {
|
||||
const 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())
|
||||
const { frameCount, frameSize, pixels } = readAssetFrames(assetPath, { width: 64, height: 64 })
|
||||
const delta = (leftFrame, rightFrame) => {
|
||||
const leftOffset = leftFrame * frameSize
|
||||
const rightOffset = rightFrame * frameSize
|
||||
let total = 0
|
||||
for (let index = 0; index < frameSize; index += 1) {
|
||||
total += Math.abs(pixels[leftOffset + index] - pixels[rightOffset + index])
|
||||
}
|
||||
return total / frameSize
|
||||
}
|
||||
const adjacent = Array.from(
|
||||
{ length: Math.max(0, frameCount - 1) },
|
||||
(_, index) => delta(index, index + 1)
|
||||
).sort((left, right) => left - right)
|
||||
return {
|
||||
medianAdjacentDelta: adjacent[Math.floor(adjacent.length / 2)] || 0,
|
||||
seamDelta: delta(frameCount - 1, 0)
|
||||
}
|
||||
}
|
||||
|
||||
function measureGifDuration(assetPath) {
|
||||
const 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())
|
||||
const delays = execFileSync('identify', ['-format', '%T\n', assetPath], {
|
||||
encoding: 'utf8'
|
||||
}).trim().split('\n').map(Number)
|
||||
return delays.reduce((total, delay) => total + delay * 10, 0)
|
||||
}
|
||||
|
||||
function measureOrbAssetPresentation(assetPath) {
|
||||
const script = `
|
||||
from PIL import Image
|
||||
import json
|
||||
import sys
|
||||
const { frameCount, frameSize, height, pixels, width } = readAssetFrames(assetPath)
|
||||
let minimumCornerLuma = 255
|
||||
let maximumCornerLuma = 0
|
||||
let minimumBackgroundSimilarityRatio = 1
|
||||
let minimumForegroundWidthRatio = 1
|
||||
let minimumForegroundHeightRatio = 1
|
||||
|
||||
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)),
|
||||
for (let frame = 0; frame < frameCount; frame += 1) {
|
||||
const frameOffset = frame * frameSize
|
||||
const pixelAt = (x, y) => {
|
||||
const offset = frameOffset + (y * width + x) * 3
|
||||
return [pixels[offset], pixels[offset + 1], pixels[offset + 2]]
|
||||
}
|
||||
const corners = [
|
||||
pixelAt(0, 0),
|
||||
pixelAt(width - 1, 0),
|
||||
pixelAt(0, height - 1),
|
||||
pixelAt(width - 1, height - 1)
|
||||
]
|
||||
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)
|
||||
)
|
||||
const cornerLumas = corners.map((pixel) => (pixel[0] + pixel[1] + pixel[2]) / 3)
|
||||
minimumCornerLuma = Math.min(minimumCornerLuma, ...cornerLumas)
|
||||
maximumCornerLuma = Math.max(maximumCornerLuma, ...cornerLumas)
|
||||
const background = [0, 1, 2].map((channel) => Math.round(
|
||||
corners.reduce((total, pixel) => total + pixel[channel], 0) / corners.length
|
||||
))
|
||||
let backgroundSimilarity = 0
|
||||
let minX = width
|
||||
let minY = height
|
||||
let maxX = -1
|
||||
let maxY = -1
|
||||
|
||||
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())
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const pixel = pixelAt(x, y)
|
||||
const diff = pixel.reduce(
|
||||
(total, value, channel) => total + Math.abs(value - background[channel]),
|
||||
0
|
||||
)
|
||||
if (diff > 22) {
|
||||
minX = Math.min(minX, x)
|
||||
minY = Math.min(minY, y)
|
||||
maxX = Math.max(maxX, x)
|
||||
maxY = Math.max(maxY, y)
|
||||
}
|
||||
if (diff <= 12) {
|
||||
backgroundSimilarity += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
if (maxX >= minX && maxY >= minY) {
|
||||
minimumForegroundWidthRatio = Math.min(minimumForegroundWidthRatio, (maxX - minX + 1) / width)
|
||||
minimumForegroundHeightRatio = Math.min(minimumForegroundHeightRatio, (maxY - minY + 1) / height)
|
||||
}
|
||||
minimumBackgroundSimilarityRatio = Math.min(
|
||||
minimumBackgroundSimilarityRatio,
|
||||
backgroundSimilarity / (width * height)
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
minimumCornerLuma,
|
||||
maximumCornerLuma,
|
||||
minimumBackgroundSimilarityRatio,
|
||||
minimumForegroundWidthRatio,
|
||||
minimumForegroundHeightRatio,
|
||||
width,
|
||||
height
|
||||
}
|
||||
}
|
||||
|
||||
const appShell = readSource('../src/views/AppShellRouteView.vue')
|
||||
@@ -230,7 +232,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 runtime\.selectedFileCards"[\s\S]*class="workbench-ai-file-card"/)
|
||||
assert.match(aiModeSurface, /<article[\s\S]{0,140}v-for="file in runtime\.selectedFileCards"[\s\S]{0,140}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/)
|
||||
@@ -239,7 +241,7 @@ test('AI mode screen follows the approved reference structure', () => {
|
||||
assert.match(aiModeSurface, /:aria-label="`移除附件 \$\{file\.name\}`"/)
|
||||
assert.match(aiModeSurface, /function removeAiModeFile\(fileKey\)/)
|
||||
assert.match(aiModeSurface, /const selectedFileCards = computed/)
|
||||
assert.match(aiModeSurface, /resolveAiComposerFileType\(file\)/)
|
||||
assert.match(aiModeSurface, /resolveAiComposerFileType\(file, previewAsset\)/)
|
||||
assert.match(aiModeSurface, /AI_COMPOSER_FILE_TYPE_META = \{[\s\S]*pdf:\s*\{ label:\s*'PDF'/)
|
||||
assert.match(aiModeSurface, /buildFileIdentity,[\s\S]*collectReceiptFiles[\s\S]*travelReimbursementAttachmentModel\.js/)
|
||||
assert.match(aiModeSurface, /MAX_ATTACHMENTS,[\s\S]*mergeFilesWithLimit[\s\S]*travelReimbursementAttachmentModel\.js/)
|
||||
@@ -345,7 +347,8 @@ test('AI mode screen follows the approved reference structure', () => {
|
||||
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-query-summary\)/)
|
||||
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-query-summary__scope\)/)
|
||||
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card-list\) \{[\s\S]*gap:\s*16px;/)
|
||||
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card\) \{[\s\S]*url\("\.\.\/\.\.\/ai-document-card-bg\.png"\);/)
|
||||
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card\) \{[\s\S]*background-color:\s*#ffffff;/)
|
||||
assert.doesNotMatch(aiModeStyles, /ai-document-card-bg\.png/)
|
||||
assert.doesNotMatch(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card\)::before/)
|
||||
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__head\) \{[\s\S]*background: var\(--ai-document-card-head-bg\);/)
|
||||
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card\.is-success \.ai-document-card__head\)/)
|
||||
@@ -422,7 +425,8 @@ test('AI mode screen follows the approved reference structure', () => {
|
||||
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(fileStripRule, /flex-wrap:\s*wrap;/)
|
||||
assert.match(fileStripRule, /flex-wrap:\s*nowrap;/)
|
||||
assert.match(fileStripRule, /overflow-x:\s*auto;/)
|
||||
assert.match(fileStripRule, /justify-content:\s*flex-start;/)
|
||||
assert.match(fileCardRule, /grid-template-columns:\s*48px minmax\(0,\s*1fr\) 30px;/)
|
||||
assert.match(fileCardRule, /border-radius:\s*16px;/)
|
||||
@@ -579,7 +583,7 @@ test('AI mode normal assistant requests include OCR context for uploaded receipt
|
||||
assert.match(aiModeSurface, /function buildAiModeReceiptContextCacheKey\(ocrFiles = \[\]\)/)
|
||||
assert.match(aiModeSurface, /applyAiModeReceiptRecognitionResult\(ocrFiles, context\)/)
|
||||
assert.match(aiModeSurface, /buildFileIdentity\(file\)/)
|
||||
assert.match(aiModeSurface, /watch\(selectedFiles, \(files\) => \{[\s\S]*attachmentFlow\.primeAiModeReceiptContext\(files\)/)
|
||||
assert.match(aiModeSurface, /watch\(selectedFiles, \(files(?:, previousFiles = \[\])?\) => \{[\s\S]*attachmentFlow\.primeAiModeReceiptContext\(files\)/)
|
||||
assert.match(aiModeSurface, /async function collectAiModeReceiptContext\(files = \[\]\)/)
|
||||
assert.match(aiModeSurface, /cached\?\.status === 'pending'[\s\S]*await cached\.promise/)
|
||||
assert.match(aiModeSurface, /collectReceiptFiles\(\{[\s\S]*files:\s*ocrFiles,[\s\S]*recognizeOcrFiles[\s\S]*\}\)/)
|
||||
|
||||
@@ -451,7 +451,7 @@ test('standalone reimbursement draft branch asks before creating a new draft', (
|
||||
test('personal workbench routes reimbursement creation intent to association gate before steward', () => {
|
||||
assert.match(
|
||||
personalWorkbenchAiMode,
|
||||
/import \{ isReimbursementCreationIntent \} from '\.\/workbenchAiApplicationGateModel\.js'/
|
||||
/import\s*\{\s*isReimbursementCreationIntent\s*\}\s*from '\.\/workbenchAiApplicationGateModel\.js'/
|
||||
)
|
||||
const startConversationIndex = personalWorkbenchAiMode.indexOf('function startInlineConversation')
|
||||
const gateIndex = personalWorkbenchAiMode.indexOf('expenseFlow.startAiReimbursementAssociationGate(cleanPrompt', startConversationIndex)
|
||||
|
||||
@@ -46,7 +46,8 @@ test('workbench document detail keeps workbench as the return target', () => {
|
||||
assert.match(appShell, /const detailPayload = request \|\| \{[\s\S]*detailLookupOnly:\s*true[\s\S]*\}/)
|
||||
assert.match(appShell, /openRequestDetail\(detailPayload,\s*\{ returnTo \}\)/)
|
||||
assert.match(appShellComposable, /const detailReturnTarget = computed/)
|
||||
assert.match(appShellComposable, /detailReturnTarget\.value === 'workbench' \? '返回首页' : '返回单据中心'/)
|
||||
assert.match(appShellComposable, /if \(detailReturnTarget\.value === 'workbench'\) return '返回首页'/)
|
||||
assert.match(appShellComposable, /detailReturnTarget\.value === 'value' \? '返回经营价值' : '返回单据中心'/)
|
||||
assert.match(appShellComposable, /const returnTo = resolveDocumentDetailReturnTarget\(options\.returnTo\)/)
|
||||
assert.match(appShellComposable, /nextQuery\.returnTo = returnTo/)
|
||||
assert.match(appShellComposable, /router\.push\(\{ name: 'app-workbench' \}\)/)
|
||||
@@ -57,7 +58,7 @@ test('AI conversation document detail returns to the active conversation content
|
||||
assert.match(aiDetailReference, /source:\s*'ai-conversation'/)
|
||||
assert.match(aiDetailReference, /returnTo:\s*'conversation'/)
|
||||
assert.match(appShell, /@back-to-requests="handleDocumentDetailBack"/)
|
||||
assert.match(appShell, /const DOCUMENT_DETAIL_RETURN_TARGETS = new Set\(\['workbench', 'conversation'\]\)/)
|
||||
assert.match(appShell, /const DOCUMENT_DETAIL_RETURN_TARGETS = new Set\(\['workbench', 'conversation', 'value'\]\)/)
|
||||
assert.match(
|
||||
appShell,
|
||||
/function handleDocumentDetailBack\(\) \{[\s\S]*detailReturnTarget\.value === 'conversation'[\s\S]*dispatchAiSidebarCommand\('open-recent', activeConversation\)/
|
||||
|
||||
Reference in New Issue
Block a user