427 lines
18 KiB
JavaScript
427 lines
18 KiB
JavaScript
|
|
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')
|
||
|
|
}
|