Files
shanxia2.0/web-admin/scripts/quota-statistics.test.ts
T

697 lines
23 KiB
TypeScript

import test from 'node:test'
import assert from 'node:assert/strict'
import { createStatisticsLoader } from '../src/views/pl/quota-statistics/loader'
test('fixed contract skips global list and retains each decimal balance', async () => {
const calls: string[] = []
const loader = createStatisticsLoader({
contracts: async () => {
calls.push('global')
},
contract: async (id: string) => {
calls.push(id)
return { certificates: [{ availableQty: '0.10000001' }, { availableQty: '7.2' }] }
}
} as any)
const result = await loader.load({ mode: 'contract', contractId: 'c1', allowed: true })
assert.deepEqual(calls, ['c1'])
assert.deepEqual(
result.certificates.map((c: any) => c.availableQty),
['0.10000001', '7.2']
)
})
test('permission denied never requests statistics', async () => {
const loader = createStatisticsLoader(
new Proxy(
{},
{
get: () => () => {
throw Error('unexpected request')
}
}
) as any
)
assert.equal(await loader.load({ mode: 'contract', allowed: false }), undefined)
})
test('context invalidation discards pending response', async () => {
let resolve!: (v: any) => void
const loader = createStatisticsLoader({
contract: () =>
new Promise((r) => {
resolve = r
})
} as any)
const pending = loader.load({ mode: 'contract', contractId: 'old', allowed: true })
loader.invalidate()
resolve({ id: 'old' })
assert.equal(await pending, undefined)
})
import { readFileSync } from 'node:fs'
import { parse } from '@vue/compiler-sfc'
import ts from 'typescript'
import * as vue from 'vue'
import { plRoutes } from '../src/router/modules/pl'
import { usePersistedListColumns } from '../src/composables/usePersistedListColumns'
import { useServerColumnFilters } from '../src/composables/useServerColumnFilters'
const flush = async () => {
for (let i = 0; i < 30; i++) await Promise.resolve()
}
function deferred() {
let resolve!: (value: any) => void, reject!: (error: any) => void
const promise = new Promise((a, b) => {
resolve = a
reject = b
})
return { promise, resolve, reject }
}
function harness(fixed?: string) {
const viewportWidth = vue.ref(1120)
const calls: any[] = [],
permissions: any[] = [],
unmount: Function[] = []
const props = vue.reactive({
mode: 'contract',
contractId: fixed,
active: true,
embedded: !!fixed
})
const user = vue.reactive({ accessToken: 'token', getUserInfo: { tenantNo: 'a' } })
const queue = (method: string, args: any[]) => {
const pending = deferred()
calls.push({ method, args, ...pending })
return pending.promise
}
const api = Object.fromEntries(
['contracts', 'contract', 'certificates', 'certificate'].map((method) => [
method,
(...args: any[]) => queue(method, args)
])
)
const env = {
...Object.fromEntries(
Object.entries(vue).filter(([key]) => /^[a-zA-Z_$][\w$]*$/.test(key) && key !== 'default')
),
defineProps: () => props,
withDefaults: (v: any) => v,
useUserStore: () => user,
fetchGetMenuCodes: () => {
const p = deferred()
permissions.push(p)
return p.promise
},
useElementSize: () => ({ width: viewportWidth }),
useListTableViewportHeight: () => ({ tableHeight: vue.ref(400) }),
usePersistedListColumns,
useServerColumnFilters,
createStatisticsLoader,
quotaStatisticsApi: api,
onBeforeUnmount: (fn: Function) => unmount.push(fn)
}
const src = ts.createSourceFile(
'panel.ts',
parse(
readFileSync(
new URL('../src/views/pl/quota-statistics/StatisticsPanel.vue', import.meta.url),
'utf8'
)
).descriptor.scriptSetup!.content,
ts.ScriptTarget.Latest,
true
)
const code = ts.transpile(
src.statements
.filter((s) => !ts.isImportDeclaration(s))
.map((s) => s.getText(src))
.join('\n'),
{ target: ts.ScriptTarget.ESNext }
)
const scope = vue.effectScope()
const state = scope.run(() =>
new Function(
...Object.keys(env),
`${code}; return { rows, fixedDetail, details, load, loadDetail, allowed, canEdit, keyword, setFilterState, page, total, expansionStyle }`
)(...Object.values(env))
)
return {
state,
viewportWidth,
calls,
permissions,
props,
user,
stop: () => {
unmount.forEach((fn) => fn())
scope.stop()
}
}
}
test('five leaves retain IDs and individual permissions', () => {
const group = plRoutes[0].children!.find((r) => r.id === 'PL_GROUP_CERT')!
assert.deepEqual(
group.children!.map((r) => r.path),
['quota-cert', 'contract-quota', 'certificate-usage', 'plan-cert', 'legal-use-document']
)
assert.equal(group.children![0].id, 'PL_QUOTA_CERT')
assert.equal(group.children![3].id, 'PL00000000000000000000000000015')
assert.deepEqual(
group.children!.map((r) => r.meta!.menuCode),
[
'pl:quota-cert:list',
'pl:quota-cert:list',
'pl:quota-cert:list',
'pl:plan-cert:list',
'pl:legal-use-document:list'
]
)
})
test('actual fixed panel loads only current contract and drops old response', async () => {
const h = harness('one')
h.permissions[0].resolve({ menuCodes: ['pl:quota-cert:list'] })
await flush()
assert.equal(h.calls[0].method, 'contract')
assert.deepEqual(h.calls[0].args, ['one'])
h.props.contractId = 'two'
h.permissions[1].resolve({ all: true })
await flush()
h.calls[1].resolve({ id: 'two', certificates: [] })
await flush()
h.calls[0].resolve({ id: 'one', certificates: [] })
await flush()
assert.equal(h.state.fixedDetail.value.id, 'two')
h.stop()
})
for (const change of ['tenant', 'logout', 'unmount'])
test(`actual panel invalidates data on ${change}`, async () => {
const h = harness()
h.permissions[0].resolve({ all: true })
await flush()
if (change === 'tenant') h.user.getUserInfo.tenantNo = 'b'
if (change === 'logout') h.user.accessToken = ''
if (change === 'unmount') h.stop()
h.calls[0].resolve({ list: [{ id: 'stale' }], total: 1 })
await flush()
assert.deepEqual(h.state.rows.value, [])
assert.equal(h.state.allowed.value, false)
h.stop()
})
test('proof-only permission cannot request quota', async () => {
const h = harness()
h.permissions[0].resolve({ menuCodes: ['pl:legal-use-document:list'] })
await flush()
assert.equal(h.calls.length, 0)
h.stop()
})
test('expand failure clears stale data and retry works', async () => {
const h = harness()
h.permissions[0].resolve({ all: true })
await flush()
h.calls[0].resolve({ list: [{ id: 'c' }], total: 1 })
await flush()
const first = h.state.loadDetail({ id: 'c' })
h.calls[1].resolve({ id: 'c', certificates: [] })
await first
assert.equal(h.state.details.c.data.id, 'c')
const failure = h.state.loadDetail({ id: 'c' })
h.calls[2].reject(Error('failed'))
await failure
assert.equal(h.state.details.c.data, undefined)
assert.equal(h.state.details.c.error, true)
const retry = h.state.loadDetail({ id: 'c' })
h.calls[3].resolve({ id: 'c', certificates: [] })
await retry
assert.equal(h.state.details.c.data.id, 'c')
h.stop()
})
test('statistics API maps each view to the server endpoint and preserves response decimals', async () => {
const calls: any[] = []
const raw = { list: [{ availableQty: '1.00000001' }], total: 1 }
const source = ts.createSourceFile(
'api.ts',
readFileSync(new URL('../src/api/pl/quota-cert.ts', import.meta.url), 'utf8'),
ts.ScriptTarget.Latest,
true
)
const code = ts.transpile(
source.statements
.filter((s) => !ts.isImportDeclaration(s))
.map((s) => s.getText(source).replace(/^export /, ''))
.join('\n'),
{ target: ts.ScriptTarget.ESNext }
)
const api = new Function('request', 'pickBxData', `${code};return quotaStatisticsApi`)(
{
get: (url: string, options: any) => {
calls.push({ url, options })
return Promise.resolve(raw)
}
},
(v: any) => v
)
assert.equal(
(await api.contracts({ page: 2, keyword: '合同' })).list[0].availableQty,
'1.00000001'
)
await api.certificates({ certType: 'transport', pageSize: 20 })
await api.contract('c')
await api.certificate('purchase', 'p')
assert.deepEqual(
calls.map((c) => c.url),
[
'/transport/quota-certs/statistics/contracts',
'/transport/quota-certs/statistics/certificates',
'/transport/quota-certs/contracts/c/statistics',
'/transport/quota-certs/purchase/p/statistics'
]
)
assert.deepEqual(calls[1].options.params, { certType: 'transport', pageSize: 20 })
})
import * as statisticsLabels from '../src/views/pl/quota-statistics/labels'
test('contract type tags translate all server values and legacy framework', () => {
const template = parse(
readFileSync(
new URL('../src/views/pl/quota-statistics/StatisticsPanel.vue', import.meta.url),
'utf8'
)
).descriptor.template!.content
const expression = template.match(
/v-else-if="column.prop === 'contractType'"[^>]*>\s*\{\{([\s\S]*?)\}\}/
)![1]
const renderLabel = new Function(
'row',
...Object.keys(statisticsLabels),
`return (${expression})`
)
for (const [contractType, label] of Object.entries({
sale: '销售合同',
purchase: '采购合同',
framework_sale: '框架销售合同',
framework_purchase: '框架采购合同',
framework: '框架合同',
'': '—'
})) {
assert.equal(renderLabel({ contractType }, ...Object.values(statisticsLabels)), label)
}
})
test('usage trace renders business columns and resolves contract/product by their exact identity', () => {
const sfc = parse(
readFileSync(
new URL('../src/views/pl/quota-statistics/StatisticsDetail.vue', import.meta.url),
'utf8'
)
).descriptor
const source = ts.createSourceFile(
'detail.ts',
sfc.scriptSetup!.content,
ts.ScriptTarget.Latest,
true
)
const code = ts.transpile(
source.statements
.filter((s) => !ts.isImportDeclaration(s))
.map((s) => s.getText(source))
.join('\n'),
{ target: ts.ScriptTarget.ESNext }
)
const detail = {
contracts: [
{
id: 'other-contract',
contractNo: '不匹配合同',
products: [{ contractProductId: 'product-id', model: '不匹配规格' }]
},
{
id: 'contract-id',
contractNo: 'HT-2026-01',
products: [{ contractProductId: 'product-id', model: '98%' }]
}
],
usageItems: [
{
id: 'usage-id',
contractId: 'contract-id',
contractProductId: 'product-id',
stopId: 'stop-id',
stopProductId: 'stop-product-id',
planNo: 'PL-001',
stopType: 'delivery',
productName: '硫酸',
partyName: '本段客户',
address: '本段地址'
},
{ id: 'missing', contractId: 'missing', contractProductId: 'product-id', stopType: 'load' }
]
}
const rows = new Function(
'defineProps',
'computed',
`${code}; return typeof usageRows === 'undefined' ? undefined : usageRows.value`
)(() => ({ mode: 'certificate', detail }), vue.computed)
assert.ok(rows, '追踪表应提供可读的业务列')
assert.deepEqual(
rows.map((r: any) => [r.contractNo, r.productModel, r.partyName, r.address]),
[
['HT-2026-01', '98%', '本段客户', '本段地址'],
['—', '—', '—', '—']
]
)
const table = sfc.template!.content.match(/<el-table :data="usageRows"[\s\S]*?<\/el-table>/)![0]
const props = [...table.matchAll(/<el-table-column prop="([^"]+)"/g)].map((m) => m[1])
assert.ok(['contractNo', 'productModel', 'partyName', 'address'].every((p) => props.includes(p)))
assert.ok(!props.some((p) => /Id$/.test(p)))
assert.ok(!table.includes('row.stopId'))
const stopExpression = table.match(/<el-tag size="small">\s*\{\{([\s\S]*?)\}\}/)![1]
assert.equal(new Function('row', `return (${stopExpression})`)(rows[0]), '送货')
})
test('quick search and number header filter are sent as independent conditions', async () => {
const h = harness()
h.permissions[0].resolve({ all: true })
await flush()
h.calls[0].resolve({ list: [], total: 0 })
await flush()
h.state.keyword.value = '目标合同'
h.state.setFilterState('contractNo', { text: 'HT-2026' })
assert.equal(h.calls[1].args[0].keyword, '目标合同')
assert.equal(h.calls[1].args[0].contractNo, 'HT-2026')
h.calls[1].resolve({ list: [], total: 0 })
await flush()
h.stop()
})
import { bindingPayload } from '../src/views/pl/quota-cert/model'
function scopeDialogHarness() {
const calls: any[] = [],
unmount: Function[] = []
const queue = (method: string, args: any[]) => {
const p = deferred()
calls.push({ method, args, ...p })
return p.promise
}
const api = Object.fromEntries(
['list', 'contracts', 'products', 'bindings', 'saveBindings'].map((method) => [
method,
(...args: any[]) => queue(method, args)
])
)
const source = ts.createSourceFile(
'scope.ts',
parse(
readFileSync(
new URL('../src/views/pl/quota-cert/ContractScopeDialog.vue', import.meta.url),
'utf8'
)
).descriptor.scriptSetup!.content,
ts.ScriptTarget.Latest,
true
)
const code = ts.transpile(
source.statements
.filter((s) => !ts.isImportDeclaration(s))
.map((s) => s.getText(source))
.join('\n'),
{ target: ts.ScriptTarget.ESNext }
)
const env = {
...Object.fromEntries(
Object.entries(vue).filter(([key]) => /^[a-zA-Z_$][\w$]*$/.test(key) && key !== 'default')
),
defineProps: () => ({ canEdit: true, fixedContractId: 'c1' }),
defineEmits: () => () => {},
defineExpose: () => {},
onBeforeUnmount: (fn: Function) => unmount.push(fn),
quotaCertApi: api,
quotaStatisticsApi: { contract: (...args: any[]) => queue('contract', args) },
bindingPayload,
ElMessage: { success: () => {} }
}
const scope = vue.effectScope()
const state = scope.run(() =>
new Function(
...Object.keys(env),
`${code};return {open, visible, loading, searching, loadedContractId, bindings, save, searchContracts, contracts}`
)(...Object.values(env))
)
return {
state,
calls,
stop() {
unmount.forEach((fn) => fn())
scope.stop()
}
}
}
test('closing and reopening same contract rejects old scope response and keeps save disabled', async () => {
const h = scopeDialogHarness(),
s = h.state
const first = s.open('c1')
h.calls[0].resolve([])
h.calls[1].resolve([])
h.calls[2].resolve({ id: 'c1', contractNo: 'HT-1', contractName: '合同' })
await flush()
assert.deepEqual(
h.calls.slice(3).map((c) => c.method),
['products', 'bindings']
)
s.visible.value = false
const second = s.open('c1')
h.calls[3].resolve([{ id: 'old-product' }])
h.calls[4].resolve([{ id: 'old-binding', certType: 'purchase', certId: 'p', products: [] }])
await first
assert.equal(s.loadedContractId.value, '')
assert.equal(s.loading.value, true)
assert.deepEqual(s.bindings.value, [])
const count = h.calls.length
await s.save()
assert.equal(h.calls.length, count)
h.calls[5].resolve([])
h.calls[6].resolve([])
h.calls[7].resolve({ id: 'c1', contractNo: 'HT-1', contractName: '合同' })
await flush()
h.calls[8].resolve([{ id: 'new-product' }])
h.calls[9].resolve([])
await second
assert.equal(s.loadedContractId.value, 'c1')
assert.equal(s.loading.value, false)
h.stop()
})
test('closing dialog invalidates pending searches including their finally state', async () => {
const h = scopeDialogHarness(),
s = h.state
s.visible.value = true
const old = s.searchContracts('old')
s.visible.value = false
s.visible.value = true
const fresh = s.searchContracts('new')
h.calls[0].resolve([{ id: 'old' }])
await old
assert.deepEqual(s.contracts.value, [])
assert.equal(s.searching.value, true)
h.calls[1].resolve([{ id: 'new' }])
await fresh
assert.equal(s.contracts.value[0].id, 'new')
assert.equal(s.searching.value, false)
h.stop()
})
// Mount the real Element Plus pager. Its page-count watcher must remain active.
import ElPagination from 'element-plus/es/components/pagination/src/pagination.mjs'
test('real pagination keeps page two while loading and preserves count after request failure', async () => {
const h = harness(),
s = h.state
h.permissions[0].resolve({ all: true })
await flush()
h.calls[0].resolve({ list: [{ id: 'first' }], total: 45 })
await flush()
const renderer = vue.createRenderer<any, any>({
createElement: () => ({}),
createText: () => ({}),
createComment: () => ({}),
setText: () => {},
setElementText: () => {},
parentNode: () => null,
nextSibling: () => null,
insert: () => {},
remove: () => {},
patchProp: () => {}
})
const app = renderer.createApp({
render: () =>
vue.h(ElPagination, {
currentPage: s.page.value,
total: s.total.value,
pageSize: 20,
layout: '',
'onUpdate:current-page': (value: number) => {
s.page.value = value
},
onCurrentChange: () => {
void s.load()
}
})
})
app.mount({})
s.page.value = 2
const pending = s.load()
await vue.nextTick()
await flush()
assert.equal(s.page.value, 2)
assert.equal(h.calls.length, 2)
assert.equal(h.calls[1].args[0].page, 2)
h.calls[1].resolve({ list: [{ id: 'second' }], total: 45 })
await pending
await vue.nextTick()
assert.equal(s.page.value, 2)
assert.equal(s.rows.value[0].id, 'second')
const failed = s.load()
h.calls[2].reject(Error('failed'))
await failed
await vue.nextTick()
assert.equal(s.page.value, 2)
assert.equal(s.total.value, 45)
assert.deepEqual(s.rows.value, [])
h.user.getUserInfo.tenantNo = 'next'
await vue.nextTick()
assert.equal(s.page.value, 1)
assert.equal(s.total.value, 0)
app.unmount()
h.stop()
})
test('refresh and search while permission request is pending do not cancel initialization', async () => {
const h = harness(),
s = h.state
await s.load()
s.keyword.value = '目标'
s.setFilterState('contractNo', { text: 'HT' })
assert.equal(h.calls.length, 0)
h.permissions[0].resolve({ menuCodes: ['pl:quota-cert:list'] })
await flush()
assert.equal(h.calls.length, 1)
assert.deepEqual(h.calls[0].args[0], { page: 1, pageSize: 20, keyword: '目标', contractNo: 'HT' })
h.calls[0].resolve({ list: [{ id: 'loaded' }], total: 1 })
await flush()
assert.equal(s.rows.value[0].id, 'loaded')
h.stop()
})
import { compileScript } from '@vue/compiler-sfc'
function compiledSfc(path: string, dependencies: Record<string, any>, inlineTemplate = true) {
const descriptor = parse(readFileSync(new URL(path, import.meta.url), 'utf8')).descriptor
const compiled = compileScript(descriptor, { id: path, inlineTemplate })
const source = ts.createSourceFile('compiled.ts', compiled.content, ts.ScriptTarget.Latest, true)
const env: Record<string, any> = Object.fromEntries(
Object.entries(vue).filter(([key]) => /^[a-zA-Z_$][\w$]*$/.test(key) && key !== 'default')
)
Object.assign(env, dependencies)
for (const statement of source.statements) {
if (!ts.isImportDeclaration(statement) || statement.importClause?.isTypeOnly) continue
const clause = statement.importClause
if (clause?.name) env[clause.name.text] = dependencies[clause.name.text] || {}
if (clause?.namedBindings && ts.isNamedImports(clause.namedBindings)) {
for (const item of clause.namedBindings.elements) {
if (item.isTypeOnly) continue
const name = (item.propertyName || item.name).text
env[item.name.text] =
statement.moduleSpecifier.getText(source) === '"vue"' ||
statement.moduleSpecifier.getText(source) === "'vue'"
? (vue as any)[name]
: dependencies[name]
}
}
}
const code = ts.transpile(
source.statements
.filter((s) => !ts.isImportDeclaration(s))
.map((s) => s.getText(source))
.join('\n')
.replace('export default', 'const component ='),
{ target: ts.ScriptTarget.ESNext }
)
return new Function(...Object.keys(env), `${code}; return component`)(...Object.values(env))
}
for (const wrapper of [
'contract-quota/ContractQuotaPanel.vue',
'certificate-usage/CertificateUsagePanel.vue'
]) {
for (const active of [undefined, false])
test(`compiled ${wrapper} ${active === false ? 'inactive skips requests' : 'defaults to active and loads'}`, async () => {
const calls: string[] = []
const dependencies = {
useUserStore: () => vue.reactive({ accessToken: 'token', getUserInfo: { tenantNo: 'a' } }),
fetchGetMenuCodes: async () => {
calls.push('permissions')
return { all: true }
},
quotaStatisticsApi: {
contracts: async () => {
calls.push('contracts')
return { list: [], total: 0 }
},
certificates: async () => {
calls.push('certificates')
return { list: [], total: 0 }
}
},
usePersistedListColumns,
useServerColumnFilters,
createStatisticsLoader,
useElementSize: () => ({ width: vue.ref(1120) }),
useListTableViewportHeight: () => ({ tableHeight: vue.ref(400) }),
statusLabel: statisticsLabels.statusLabel,
contractTypeLabel: statisticsLabels.contractTypeLabel
}
// Compile the real inner setup too; only its visual child tree is omitted here.
const StatisticsPanel = compiledSfc(
'../src/views/pl/quota-statistics/StatisticsPanel.vue',
dependencies,
false
)
StatisticsPanel.render = () => null
const Wrapper = compiledSfc(`../src/views/pl/${wrapper}`, { StatisticsPanel })
const renderer = vue.createRenderer<any, any>({
createElement: () => ({}),
createText: () => ({}),
createComment: () => ({}),
setText: () => {},
setElementText: () => {},
parentNode: () => null,
nextSibling: () => null,
insert: () => {},
remove: () => {},
patchProp: () => {}
})
const app = renderer.createApp(Wrapper, active === undefined ? {} : { active })
app.mount({})
await flush()
await vue.nextTick()
assert.deepEqual(
calls,
active === false
? []
: ['permissions', wrapper.startsWith('contract') ? 'contracts' : 'certificates']
)
app.unmount()
})
}
test('expanded content follows the table viewport width and keeps alignment local', () => {
const h = harness()
assert.equal(h.state.expansionStyle.value.width, '1120px')
h.viewportWidth.value = 760
assert.equal(h.state.expansionStyle.value.width, '760px')
const sfc = parse(
readFileSync(
new URL('../src/views/pl/quota-statistics/StatisticsPanel.vue', import.meta.url),
'utf8'
)
).descriptor
assert.match(
sfc.template!.content,
/class="view-section statistics-expansion"\s+:style="expansionStyle"/
)
assert.equal(sfc.styles[0].scoped, true)
assert.match(sfc.styles[0].content, /text-align: left/)
h.stop()
})