feat(pl): 实现合同产品用证与额度结转以支持购买证和运输证全流程管理。

This commit is contained in:
wuzilong committed 2026-11-01 18:56:05 +08:00
1 parent cb0344498a
commit fb866c7fa7
104 files changed
+10187 -3833

No files matched your search

@@ -3,7 +3,7 @@ import test from 'node:test'
import { readFileSync } from 'node:fs'
import { parse } from '@vue/compiler-sfc'
import ts from 'typescript'
import { computed, effectScope, ref, watch, nextTick } from 'vue'
import { computed, effectScope } from 'vue'
import { useMobilePlanListDetails } from '../src/views/m/pl/plan/mobileListDetails'
import { formatBrandConstraintLabel } from '../src/utils/crm-product-brand'
import { getPlanProductActualQuantity } from '../src/utils/pl-display'
@@ -103,75 +103,4 @@ test('certificate expanded card joins list standard names, preserves segment emp
scope.stop()
})
test('actual certificate list expands only the requested card and fetches mobile segment metadata', async () => {
const source = parse(
readFileSync(new URL('../src/views/m/pl/plan-cert/index.vue', import.meta.url), 'utf8')
).descriptor.scriptSetup!.content
const ast = ts.createSourceFile('list.ts', source, ts.ScriptTarget.Latest, true)
const code = ast.statements
.filter(
(n) =>
(ts.isVariableStatement(n) &&
n.declarationList.declarations.some((d) => d.name.getText(ast) === 'planDetails')) ||
(ts.isFunctionDeclaration(n) && n.name?.text === 'toggleExpanded') ||
(ts.isExpressionStatement(n) &&
ts.isCallExpression(n.expression) &&
n.expression.expression.getText(ast) === 'watch' &&
n.expression.arguments[0]?.getText(ast) === 'expandedIds')
)
.map((n) => n.getText(ast))
.join('\n')
const calls: any[] = []
const env = {
useMobilePlanListDetails,
watch,
expandedIds: ref(new Set()),
requestKey: () => 'scope',
planApi: {
get: (id: string, mobile: boolean) =>
new Promise((resolve) => calls.push({ id, mobile, resolve }))
}
}
const scope = effectScope()
const api = scope.run(() =>
new Function(
...Object.keys(env),
`${ts.transpile(code, { target: ts.ScriptTarget.ESNext })};return {planDetails,toggleExpanded}`
)(...Object.values(env))
)
assert.equal(calls.length, 0)
api.toggleExpanded('A', true)
await nextTick()
assert.deepEqual(
calls.map(({ id, mobile }) => [id, mobile]),
[['A', true]]
)
assert.equal(api.planDetails.records.A.plan, null)
calls[0].resolve({
id: 'A',
products: [{ id: 'p', productName: '根别名' }],
deliveries: [
{
products: [
{
planProductId: 'p',
mobileProductMeta: { aliasName: '段次别名', brandMode: 'any', brandName: '' }
}
]
}
]
})
await api.planDetails.ensure('A')
const plan = api.planDetails.cardPlan({
id: 'A',
products: [{ id: 'p', productName: '标准产品' }]
})
const rendered = card(plan)
assert.equal(rendered.productNameLabel.value, '标准产品')
assert.equal(rendered.productLine(plan.deliveries[0].products[0], 'delivery').alias, '段次别名')
api.toggleExpanded('A', false)
api.toggleExpanded('A', true)
await nextTick()
assert.equal(calls.length, 1)
scope.stop()
})
// Retired attachment/ready list expansion is replaced by real quota list coverage in quota-mobile-state.test.ts.
@@ -99,334 +99,3 @@ test('close/reopen, context, account and disposal invalidate requests and confir
assert.equal(h.api.confirm(), null)
assert.deepEqual(h.api.state.options, [])
})
// Execute the actual SFC setup with Vue reactivity; only transport/store/UI effects are substituted.
import { readFileSync } from 'node:fs'
import { parse } from '@vue/compiler-sfc'
import ts from 'typescript'
import * as vue from 'vue'
import { useServerColumnFilters } from '../src/composables/useServerColumnFilters'
import { plPlanCertColumnFilterDefs as defs } from '../src/views/pl/plan-cert/columnFilterDefs'
import {
withPlanContractIoFacetOptions,
PL_PLAN_CONTRACT_SIDE_FILTER_OPTIONS
} from '../src/utils/pl-plan-contract-display'
import {
applyMergedCustomerQueryParams,
applyContractSideQueryParams
} from '../src/utils/pl-plan-pair-list-query'
import { PL_PLAN_DEFAULT_FOLLOW_STATUSES, plPlanCertTabToFilters } from '../src/utils/pl-display'
import { buildQuery, facetPlanDateParams } from '../src/views/m/pl/plan-cert/model'
function component() {
const scope = effectScope()
const requests: any[] = [],
events: any[] = [],
warnings: string[] = []
const props = reactive({
modelValue: false,
filters: {} as any,
sort: { sortField: '', sortOrder: 'desc' },
context: {
planType: 'outbound',
certUploadStatus: 'incomplete',
planDateFrom: '2026-10-01',
followStatus: 'accepted'
} as any
})
const source = parse(
readFileSync(new URL('../src/views/m/pl/plan-cert/CertFilters.vue', import.meta.url), 'utf8')
).descriptor.scriptSetup!.content
const ast = ts.createSourceFile('setup.ts', source, ts.ScriptTarget.Latest, true)
const code = ts.transpile(
ast.statements
.filter((n) => !ts.isImportDeclaration(n))
.map((n) => n.getText(ast))
.join('\n'),
{ target: ts.ScriptTarget.ESNext }
)
const deps = {
ref: vue.ref,
computed: vue.computed,
watch: vue.watch,
defs,
useServerColumnFilters,
useMobileCertFacet,
withPlanContractIoFacetOptions,
PL_PLAN_CONTRACT_SIDE_FILTER_OPTIONS,
applyMergedCustomerQueryParams,
applyContractSideQueryParams,
PL_PLAN_DEFAULT_FOLLOW_STATUSES,
facetPlanDateParams,
useUserStore: () => reactive({ accessToken: 'a', getUserInfo: { tenantNo: 'a' } }),
fetchPlanColumnFacetValues: (params: any) =>
new Promise((resolve, reject) => requests.push({ params, resolve, reject })),
defineProps: () => props,
defineEmits:
() =>
(...args: any[]) =>
events.push(args),
ElMessage: { warning: (v: string) => warnings.push(v) }
}
const api = scope.run(() =>
new Function(
...Object.keys(deps),
`${code}; return {picker,draft,apply,resetAndApply,confirmPicker,optionLabel,pickerOptions}`
)(...Object.values(deps))
)
return { api, props, requests, events, warnings, scope }
}
test('component preserves query semantics and confirmation is isolated until accepted', async () => {
const h = component()
h.props.modelValue = true
await nextTick()
Object.assign(h.api.draft, {
followStatus: { selected: ['accepted'], text: '' },
planType: { selected: ['inbound'], text: '' },
hazardCertReady: { selected: ['1'], text: '' },
productContract: { selected: ['load', 'C-1'], text: '' }
})
h.api.picker.open('planNo', [])
const q = h.requests[0].params
assert.equal(q.planType, 'outbound')
assert.equal(q.hazardCertReady, 0)
assert.equal(q.hazardCertReadyIn, undefined)
assert.equal(q.followStatus, 'accepted')
assert.equal(q.planDateFrom, '2026-10-01')
assert.equal(q.stopKindIn, 'load')
h.api.picker.toggle('P-1')
assert.equal(h.api.draft.planNo, undefined)
h.api.confirmPicker()
assert.deepEqual(h.api.draft.planNo.selected, ['P-1'])
h.api.picker.open('productContract', ['load', 'C-1'])
h.api.picker.search('no match')
assert.equal(h.api.optionLabel('load'), '供')
assert.equal(h.api.optionLabel('C-1'), 'C-1')
h.scope.stop()
})
test('component date/number validation, normal apply, reset and closing discard picker', async () => {
const h = component()
h.props.modelValue = true
await nextTick()
h.api.draft.planDate = { from: '2026-10-02', to: '2026-10-01' }
h.api.apply()
assert.equal(h.events.length, 0)
assert.match(h.warnings[0], /开始不能晚于结束/)
delete h.api.draft.planDate
const number = defs.find((d) => d.type === 'number')!
h.api.draft[number.prop] = { op: 'between', min: '9', max: '2' }
h.api.apply()
assert.equal(h.events.length, 0)
assert.match(h.warnings[1], /最小值不能大于最大值/)
h.api.draft[number.prop] = { op: 'between', min: '2', max: '9' }
h.api.apply()
assert.equal(h.events[0][0], 'apply')
h.api.resetAndApply()
assert.deepEqual(h.events[2][1].followStatus.selected, PL_PLAN_DEFAULT_FOLLOW_STATUSES)
assert.equal(h.events[2][3], true)
h.api.picker.open('planNo', [])
h.props.modelValue = false
await nextTick()
assert.equal(h.api.picker.confirm(), null)
h.scope.stop()
})
test('component independent customer sides, contract-side stripping and draft date replacement use real query helpers', async () => {
const h = component()
h.props.modelValue = true
await nextTick()
Object.assign(h.api.draft, {
loadCustomer: { selected: ['装货甲'], text: '' },
deliveryCustomer: { selected: ['送货乙'], text: '' },
productContract: { selected: ['load', 'C-1'], text: '' },
planDate: { from: '2026-10-08', to: '2026-10-09' }
})
h.api.picker.open('planNo', [])
assert.equal(h.requests[0].params.loadCustomersIn, '装货甲')
assert.equal(h.requests[0].params.deliveryCustomersIn, '送货乙')
assert.equal(h.requests[0].params.stopCustomers, undefined)
assert.equal(h.requests[0].params.contractNoIn, 'C-1')
assert.equal(h.requests[0].params.planDateFrom, '2026-10-08')
h.api.picker.open('loadCustomer', [])
assert.equal(h.requests[1].params.stopCustomers, undefined)
h.api.picker.open('productContract', ['load', 'C-1'])
assert.equal(h.requests[2].params.stopKindIn, undefined)
assert.equal(h.requests[2].params.contractNoIn, undefined)
assert.deepEqual(
h.api.pickerOptions.value.map((o: any) => o.label),
['供', '需', '出库', '入库']
)
h.scope.stop()
})
test('confirmed contract summary retains side labels when another picker is active', async () => {
const h = component()
h.props.modelValue = true
await nextTick()
h.api.picker.open('productContract', ['load', 'delivery', 'C-1', '出库'])
h.api.confirmPicker()
h.api.picker.open('planNo', [])
const labels = h.api.draft.productContract.selected.map((value: string) =>
h.api.optionLabel(value, 'productContract')
)
assert.deepEqual(labels, ['供', '需', 'C-1', '出库'])
assert.equal(h.api.optionLabel('load', 'planNo'), 'load')
h.scope.stop()
})
import { Search as VanSearch } from 'vant'
test('actual Vant Search mounts a native clear button whose activation preserves picker and selection', async () => {
const h = component()
h.props.modelValue = true
await nextTick()
h.api.picker.open('planNo', ['kept'])
h.api.picker.search('PO20261021002')
const source = parse(
readFileSync(new URL('../src/views/m/pl/plan-cert/CertFilters.vue', import.meta.url), 'utf8')
).descriptor.template!.content
const searchTemplate = source.match(/<van-search\b[\s\S]*?(?:\/>|<\/van-search>)/)![0]
type Node = { type: string; props: any; children: Node[]; parent?: Node }
const renderer = vue.createRenderer<Node, Node>({
createElement: (type) => ({ type, props: {}, children: [] }),
createText: (text) => ({ type: '#text', props: { text }, children: [] }),
createComment: () => ({ type: '#comment', props: {}, children: [] }),
setText: (node, text) => {
node.props.text = text
},
setElementText: (node, text) => {
node.props.text = text
node.children = []
},
parentNode: (node) => node.parent || null,
nextSibling: () => null,
patchProp: (node, key, _old, value) => {
node.props[key] = value
},
insert: (node, parent) => {
node.parent = parent
parent.children.push(node)
},
remove: (node) => {
if (node.parent) node.parent.children = node.parent.children.filter((n) => n !== node)
}
})
const root: Node = { type: 'root', props: {}, children: [] }
const app = renderer.createApp({
components: { VanSearch },
setup: () => ({
picker: h.api.picker,
pickerState: h.api.picker.state,
pickerTitle: '计划编号'
}),
render: vue.compile(searchTemplate)
})
app.mount(root)
await nextTick()
const find = (node: Node): Node | undefined =>
node.type === 'button' && node.props['aria-label'] === '清空搜索'
? node
: node.children.map(find).find(Boolean)
const clear = find(root)
assert.ok(
clear,
'Search must expose a real button usable by mouse, touch-generated click and keyboard'
)
// Touch generates click too; both interaction paths use the same accessible native control.
for (const pointerType of ['mouse', 'touch', 'keyboard']) {
h.api.picker.search('PO20261021002')
await nextTick()
const activeClear = find(root)!
assert.ok(activeClear)
if (pointerType === 'mouse') {
let prevented = false
activeClear.props.onMousedown({
preventDefault() {
prevented = true
}
})
assert.equal(prevented, true)
}
activeClear.props.onClick({
type: 'click',
pointerType,
stopPropagation() {},
preventDefault() {}
})
await nextTick()
assert.equal(h.api.picker.state.keyword, '')
assert.equal(find(root), undefined)
assert.equal(h.api.picker.state.visible, true)
assert.deepEqual(h.api.picker.state.selected, ['kept'])
assert.equal(h.requests.at(-1).params.keyword, '')
}
app.unmount()
h.scope.stop()
})
test('contract search filters static IO choices and has a true empty state', async () => {
const h = component()
h.props.modelValue = true
await nextTick()
h.api.picker.open('productContract', ['load'])
h.api.picker.search('zz-no-contract')
h.requests.at(-1).resolve({ list: [] })
await nextTick()
await flush()
assert.deepEqual(h.api.pickerOptions.value, [])
assert.deepEqual(h.api.picker.state.selected, ['load'])
h.api.picker.search('入库')
assert.deepEqual(
h.api.pickerOptions.value.map((o: any) => o.label),
['入库']
)
h.scope.stop()
})
for (const tab of ['all', 'cert_incomplete', 'inbound', 'outbound']) {
test(`certificate ${tab} list query matches Web for independent customers and compound filters`, () => {
const { filters, toQueryParams } = useServerColumnFilters(defs)
Object.assign(filters, {
followStatus: { text: '', selected: ['accepted'] },
loadCustomer: { text: '', selected: ['装货甲'] },
deliveryCustomer: { text: '', selected: ['送货乙'] },
productContract: { text: '', selected: ['load', 'C-1'] },
hazardCertReady: { text: '', selected: ['1'] }
})
const sort = { sortField: 'planDate', sortOrder: 'desc' }
const env = {
filters,
toQueryParams,
sort,
buildQuery,
plPlanCertTabToFilters,
applyMergedCustomerQueryParams,
applyContractSideQueryParams,
tab: vue.ref(tab),
activePlanTypeTab: vue.ref(tab),
appliedKeyword: vue.ref(' 运输 '),
quickSearch: vue.ref(' 运输 '),
dates: vue.ref(['2026-10-01', '2026-10-24']),
dateRange: vue.ref(['2026-10-01', '2026-10-24']),
toSortQueryParams: () => sort
}
const evaluate = (path: string, name: string) => {
const ast = ts.createSourceFile(
'query.ts',
parse(readFileSync(new URL(path, import.meta.url), 'utf8')).descriptor.scriptSetup!.content,
ts.ScriptTarget.Latest,
true
)
const fn = ast.statements.find((n) => ts.isFunctionDeclaration(n) && n.name?.text === name)!
return new Function(
...Object.keys(env),
`${ts.transpile(fn.getText(ast), { target: ts.ScriptTarget.ESNext })};return ${name}()`
)(...Object.values(env))
}
const mobile = evaluate('../src/views/m/pl/plan-cert/index.vue', 'query')
assert.deepEqual(
mobile,
evaluate('../src/views/pl/plan-cert/index.vue', 'buildListQueryParams')
)
assert.equal(mobile.loadCustomersIn, '装货甲')
assert.equal(mobile.deliveryCustomersIn, '送货乙')
})
}
+10 -246
View File
@@ -79,7 +79,7 @@ function harness(file: string) {
run: (reset: boolean) => api.run(fn === 'load' ? !reset : reset)
}
}
for (const file of ['plan/index.vue', 'plan-cert/index.vue']) {
for (const file of ['plan/index.vue']) {
test(`${file}: latest search wins including stale failure and finally`, async () => {
const h = harness(file)
const old = h.run(true)
@@ -138,7 +138,7 @@ for (const file of ['plan/index.vue', 'plan-cert/index.vue']) {
})
}
function actionHarness(file: string, overrides: any = {}) {
function actionHarness(file: string) {
const userStore = reactive({
accessToken: 'session-a',
getUserInfo: { tenantNo: 'tenant-a', roles: ['sales'] }
@@ -151,7 +151,6 @@ function actionHarness(file: string, overrides: any = {}) {
const plan = { id: 'p1', products: [] }
const state: any = {
acting: ref(false),
canReady: ref(true),
morePlan: ref(plan),
moreOpen: ref(true),
voidTarget: ref(plan),
@@ -161,19 +160,14 @@ function actionHarness(file: string, overrides: any = {}) {
canWaitNotice: () => true,
canCancelWaitNotice: () => true,
canVoidPlan: () => true,
readyTargets: (rows: any[]) => rows,
refreshSidebarBadges: async () => {},
planHasHazardProducts: () => true,
HAZARD_CERT_READY_CONFIRM_HINT: '',
showCrmConfirmDialog: () => {
events.push('confirm')
return confirm
},
planApi: {
get: async (id: string) => ({ id, products: [] }),
setHazardCertReady: async (id: string) => {
events.push(`ready:${id}`)
},
cancel: async (id: string, data: any) => {
events.push(`void:${id}:${data.cancelReason}`)
},
@@ -202,8 +196,7 @@ function actionHarness(file: string, overrides: any = {}) {
ts.ScriptTarget.Latest,
true
)
Object.assign(state.planApi, overrides.planApi || {})
const names = ['setReady', 'runMoreAction', 'confirmVoidPlan']
const names = ['runMoreAction', 'confirmVoidPlan']
const code = ts.transpile(
source.statements
.filter(
@@ -218,7 +211,7 @@ function actionHarness(file: string, overrides: any = {}) {
)
const api = new Function(
...Object.keys(state),
`let disposed=false; let actionVersion=0; ${code}; return { run: ${file.startsWith('plan-cert') ? '(row={id:"p1"})=>setReady(typeof row==="object"?row:{id:"p1"},true)' : '(key)=>key==="void" ? confirmVoidPlan() : runMoreAction(key)'}, dispose:()=>{disposed=true;actionVersion++}, invalidate:()=>{actionVersion++;acting.value=false} }`
`let disposed=false; let actionVersion=0; ${code}; return { run: (key)=>key==="void" ? confirmVoidPlan() : runMoreAction(key), dispose:()=>{disposed=true;actionVersion++}, invalidate:()=>{actionVersion++;acting.value=false} }`
)(...Object.values(state))
return {
state,
@@ -230,10 +223,10 @@ function actionHarness(file: string, overrides: any = {}) {
}
}
}
for (const kind of ['ready', 'wait', 'cancel-wait', 'void']) {
for (const kind of ['wait', 'cancel-wait', 'void']) {
for (const invalidation of ['dispose', 'scope', 'session']) {
test(`${kind}: confirmation followed by ${invalidation} in same tick does not write`, async () => {
const h = actionHarness(kind === 'ready' ? 'plan-cert/index.vue' : 'plan/index.vue')
const h = actionHarness('plan/index.vue')
const pending = h.run(kind)
for (let n = 0; n < 5; n++) await Promise.resolve()
assert.ok(h.events.includes('confirm'))
@@ -246,7 +239,7 @@ for (const kind of ['ready', 'wait', 'cancel-wait', 'void']) {
})
}
test(`${kind}: duplicate confirmation is locked before dialog, captured target/reason is written once`, async () => {
const h = actionHarness(kind === 'ready' ? 'plan-cert/index.vue' : 'plan/index.vue')
const h = actionHarness('plan/index.vue')
const pending = h.run(kind)
for (let n = 0; n < 5; n++) await Promise.resolve()
const duplicate = h.run(kind)
@@ -387,7 +380,7 @@ function mountTemplate(file: string, mode = 'manage') {
return { state, find, app }
}
for (const file of ['plan/index.vue', 'plan-cert/index.vue']) {
for (const file of ['plan/index.vue']) {
test(`${file}: header input survives reload, empty result, error and retry`, async () => {
const { state, find, app } = mountTemplate(file)
const input = find('input')
@@ -502,7 +495,7 @@ function returnStateHarness(base = '/m/pl/plan') {
return { create, navigate, openDetail, user, scrolls }
}
for (const base of ['/m/pl/plan', '/m/pl/plan-cert']) {
for (const base of ['/m/pl/plan']) {
test(`${base}: details restore filters/pages/expanded/scroll with no row cache`, async () => {
const env = returnStateHarness(base)
const list = env.create()
@@ -549,49 +542,7 @@ const flush = async () => {
for (let n = 0; n < 20; n++) await Promise.resolve()
}
for (const response of [{ id: 'B', products: [] }, { products: [] }, null]) {
test(`ready: deferred GET A returning ${JSON.stringify(response)} cannot confirm or write another record`, async () => {
const get = deferred()
const h = actionHarness('plan-cert/index.vue', { planApi: { get: () => get.promise } })
h.confirmResolve()
const pending = h.run({ id: 'A' })
await flush()
assert.deepEqual(h.events, [])
get.resolve({ data: response })
await pending
assert.deepEqual(h.events, ['toast'])
assert.equal(h.state.acting.value, false)
})
}
for (const failure of ['get', 'write']) {
test(`ready: delayed ${failure} failure and revoked permission cannot show stale result or refresh`, async () => {
const pendingApi = deferred()
const gets: string[] = [],
writes: string[] = []
const h = actionHarness('plan-cert/index.vue', {
planApi: {
get: (id: string) => {
gets.push(id)
return failure === 'get' ? pendingApi.promise : Promise.resolve({ id, products: [] })
},
setHazardCertReady: (id: string) => {
writes.push(id)
return pendingApi.promise
}
}
})
h.confirmResolve()
const pending = h.run({ id: 'A' })
await flush()
h.state.canReady.value = false
pendingApi.reject(new Error('late failure'))
await pending
assert.deepEqual(gets, ['A'])
assert.deepEqual(writes, failure === 'get' ? [] : ['A'])
assert.deepEqual(h.events, failure === 'get' ? [] : ['confirm'])
})
}
for (const file of ['plan/index.vue', 'plan-cert/index.vue']) {
for (const file of ['plan/index.vue']) {
for (const outcome of ['success', 'error']) {
test(`${file}: actual requestKey and sessionScope discard old ${outcome} after tenant change`, async () => {
const h = harness(file)
@@ -744,190 +695,3 @@ for (const change of ['token', 'tenant', 'roles']) {
h.stop()
})
}
// Execute the production setup and Vue watchers, stubbing only external services/hooks.
function certSessionHarness() {
const lists: any[] = [],
permissions: any[] = [],
gets: any[] = [],
writes: any[] = []
const events: string[] = []
const user = reactive({ accessToken: 'a', getUserInfo: { tenantNo: 'a', roles: ['sales'] } })
const queue = (items: any[], params?: any) => {
const request = deferred()
items.push({ ...request, params })
return request.promise
}
const env = {
...Object.fromEntries(
Object.entries(vue).filter(([key]) => /^[a-zA-Z_$][\w$]*$/.test(key) && key !== 'default')
),
useUserStore: () => user,
useRouter: () => ({ push: (path: string) => events.push(path) }),
useMenuStore: () => ({ refreshSidebarBadges: async () => {} }),
onBeforeUnmount: () => {},
useDebounceFn: (fn: any) => fn,
useIntersectionObserver: () => {},
useListReturnState: () => ({ restoring: ref(false), restore: () => events.push('restore') }),
useMobilePlanListDetails: () => ({ reset() {}, ensure: async () => true, records: {} }),
PL_PLAN_DEFAULT_FOLLOW_STATUSES: ['pending'],
plPlanCertColumnFilterDefs: [],
useServerColumnFilters: (_defs: any, options: any) => {
const filters = reactive(options.initialFilters)
return { filters, toQueryParams: () => ({ ...filters }) }
},
buildQuery: (tab: string, filters: any, keyword: string, dates: any) => ({
tab,
filters,
keyword,
dates
}),
applyMergedCustomerQueryParams: () => {},
applyContractSideQueryParams: () => {},
canEdit: () => true,
readyTargets: (rows: any[]) => rows,
planHasHazardProducts: () => true,
HAZARD_CERT_READY_CONFIRM_HINT: '',
showCrmConfirmDialog: async () => {
events.push('confirm')
},
ElMessage: Object.assign(() => events.push('toast'), { warning: () => events.push('toast') }),
planApi: {
list: (params: any) => queue(lists, params),
get: (id: string) => queue(gets, id),
setHazardCertReady: (id: string) => queue(writes, id)
},
fetchGetMenuCodes: () => queue(permissions)
}
const source = ts.createSourceFile(
'page.ts',
parse(readFileSync(new URL('plan-cert/index.vue', root), '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 scope = vue.effectScope()
const state = scope.run(() =>
new Function(
...Object.keys(env),
`${code}; return { load, loadPermissions, setReady, saved, openUpload, userStore, plans, expandedIds, total, page, loading, acting, error, failedMore, canUpload, canReady, canPrice, permissionsLoaded, permissionError, uploadOpen, uploadId, keyword, appliedKeyword, dates, dateKind, readyKind, tab, filters, sort, filtersOpen, ${code.includes('function reloadSession') ? 'reloadSession, sessionChanged, uploadRef,' : ''} }`
)(...Object.values(env))
)
return { state, user, lists, permissions, gets, writes, events, stop: () => scope.stop() }
}
for (const change of ['token', 'tenant', 'roles']) {
test(`cert session: ${change} synchronously clears private state and recovers without old callbacks`, async () => {
const h = certSessionHarness(),
s = h.state
s.plans.value = [{ id: 'private' }]
s.loading.value = false
s.total.value = 40
s.page.value = 2
s.expandedIds.value = new Set(['private'])
s.keyword.value = s.appliedKeyword.value = 'private keyword'
s.filters.customer = { text: 'private customer' }
s.filtersOpen.value = true
const oldPermission = s.loadPermissions()
s.canUpload.value = s.canReady.value = s.canPrice.value = true
s.openUpload({ id: 'private' })
let invalidations = 0
if (s.uploadRef) s.uploadRef.value = { invalidateRecord: () => invalidations++ }
const oldAction = s.setReady({ id: 'private' }, true)
const oldList = s.load(true)
h.user.getUserInfo = { tenantNo: 'a', roles: ['sales'] }
assert.equal(s.loading.value, true, 'same-content user info does not invalidate')
if (change === 'token') h.user.accessToken = 'b'
if (change === 'tenant') h.user.getUserInfo.tenantNo = 'b'
if (change === 'roles') h.user.getUserInfo.roles = ['viewer']
assert.deepEqual(s.plans.value, [], 'old tenant rows must clear synchronously')
assert.equal(s.loading.value, false)
assert.equal(s.acting.value, false)
for (const name of ['canUpload', 'canReady', 'canPrice', 'uploadOpen', 'filtersOpen'])
assert.equal(s[name].value, false)
assert.equal(s.uploadId.value, '')
assert.equal(invalidations, 1, 'child must invalidate before Vue propagates props')
assert.equal(s.total.value, 0)
assert.equal(s.page.value, 1)
assert.equal(s.expandedIds.value.size, 0)
assert.equal(s.keyword.value + s.appliedKeyword.value, '')
assert.equal(s.filters.customer, undefined)
assert.equal(s.sessionChanged.value, true)
await s.saved('private')
assert.equal(h.lists.length, 1, 'late upload success cannot trigger reload')
const recovery = s.reloadSession()
assert.equal(h.lists[1].params.page, 1)
assert.equal(h.lists[1].params.keyword, '')
h.permissions[0].resolve({ all: true })
h.lists[0].reject(new Error('old tenant failed'))
h.gets[0].resolve({ id: 'private', products: [] })
await Promise.all([oldPermission, oldList, oldAction])
assert.equal(s.loading.value, true, 'old finally cannot release fresh load')
assert.equal(s.canUpload.value, false)
assert.equal(s.error.value, '')
assert.deepEqual(h.events, [])
h.permissions[1].resolve({ menuCodes: ['pl:plan-cert:upload'] })
h.lists[1].resolve({ list: [{ id: 'fresh' }], total: 1 })
await recovery
assert.deepEqual(s.plans.value, [{ id: 'fresh' }])
assert.equal(s.canUpload.value, true)
assert.equal(s.canReady.value, false)
assert.equal(s.loading.value, false)
h.stop()
})
}
for (const outcome of ['success', 'failure']) {
test(`cert session: late ${outcome} write cannot release a new action or reload`, async () => {
const h = certSessionHarness(),
s = h.state
s.canReady.value = true
const old = s.setReady({ id: 'private' }, true)
h.gets[0].resolve({ id: 'private', products: [] })
await flush()
assert.equal(h.writes.length, 1)
h.user.accessToken = 'b'
s.canReady.value = true
const fresh = s.setReady({ id: 'fresh' }, true)
if (outcome === 'success') h.writes[0].resolve()
else h.writes[0].reject(new Error('late write failure'))
await old
assert.equal(s.acting.value, true)
assert.equal(h.lists.length, 0)
assert.deepEqual(h.events, ['confirm'])
h.gets[1].resolve({ id: 'fresh', products: [] })
await flush()
h.writes[1].resolve()
await flush()
assert.equal(h.lists.length, 1)
h.lists[0].resolve({ list: [{ id: 'fresh' }], total: 1 })
await fresh
assert.equal(s.acting.value, false)
h.stop()
})
}
for (const changed of [false, true]) {
test(`cert session: upload success ${changed ? 'cannot navigate after scope change during refresh' : 'navigates after normal child close'}`, async () => {
const h = certSessionHarness(),
s = h.state
s.canUpload.value = true
s.openUpload({ id: 'target' })
s.uploadOpen.value = false // The child closes its v-model before emitting success.
const saved = s.saved('target')
assert.equal(h.lists.length, 1)
if (changed) h.user.accessToken = 'b'
h.lists[0].resolve({ list: [{ id: 'target' }], total: 1 })
await saved
assert.deepEqual(h.events, changed ? [] : ['/m/pl/plan-cert/target'])
assert.deepEqual(s.plans.value, changed ? [] : [{ id: 'target' }])
h.stop()
})
}
@@ -30,8 +30,10 @@ function evaluate(names: string[], bindings: Record<string, any>) {
`;return {${names.join(',')}}`
)(...Object.values(bindings))
}
test('mobile has a separate first root; desktop root retained', () => {
const roots = descriptor.template!.ast.children.filter((n: any) => n.type === 1) as any[]
test('mobile and desktop business roots remain separate beside the shared quota overlay', () => {
const roots = descriptor.template!.ast.children.filter(
(n: any) => n.type === 1 && n.tag !== 'PlPlanQuotaDialog'
) as any[]
assert.equal(roots[0].tag, 'MobilePlPlanForm')
assert.match(roots[1].loc.source, /v-else/)
})
@@ -72,7 +72,6 @@ function harness(page: string, overrides: Record<string, any> = {}, initialProps
writes.push(args)
return { data: { id: args[0], products: [] } }
},
setHazardCertReady: async (...args: any[]) => writes.push(args),
updateProductCerts: async (...args: any[]) => writes.push(args),
...overrides.planApi
}
@@ -262,18 +261,6 @@ test('arrange opt-in old internal info cannot publish after reset', async () =>
assert.equal(a.driverUrl.value, '')
assert.deepEqual(a.siteRecords.value, [])
})
test('cert parent delayed confirmation invalidated synchronously', async () => {
const confirm = deferred()
const h = harness('plan-cert/detail', { showCrmConfirmDialog: () => confirm.promise })
await h.init()
const pending = h.setReady({ id: 'A' }, true)
await flush()
confirm.resolve()
h.route.params.id = 'B'
await pending
assert.deepEqual(h.writes, [])
h.unmount()
})
test('cert upload old successful save cannot close or notify new record', async () => {
const saved = deferred()
const h = harness('plan-cert/CertUpload', {
@@ -491,94 +478,6 @@ test('arrange QR nextTick cannot copy newly bound dialog after reset', async ()
assert.equal(copies, 0)
assert.equal(a.qrcodeVisible.value, false)
})
for (const stage of ['get', 'confirm', 'write'])
for (const reject of [false, true])
test(`cert ready ${stage}: switched/ABA late ${reject ? 'error' : 'success'} ignored`, async () => {
const old = deferred(),
writes: any[] = []
const h = harness('plan-cert/detail', {
showCrmConfirmDialog: stage === 'confirm' ? () => old.promise : async () => {},
planApi: {
...(stage === 'get' ? { get: () => old.promise } : {}),
setHazardCertReady: (...args: any[]) => {
writes.push(args)
return stage === 'write' ? old.promise : Promise.resolve()
}
}
})
await h.init()
const pending = h.setReady({ id: 'A' }, true)
await flush()
h.route.params.id = 'B'
h.route.params.id = 'A'
h.acting.value = true
if (reject) old.reject(new Error('old'))
else old.resolve({ data: { id: 'A', products: [{ isToxic: 1 }] } })
await pending
assert.equal(writes.length, stage === 'write' ? 1 : 0)
assert.deepEqual(h.messages, [])
assert.equal(h.acting.value, true)
h.unmount()
})
for (const bad of [null, {}, { id: 'B', products: [{}] }])
test(`cert ready invalid read ${JSON.stringify(bad)}`, async () => {
const h = harness('plan-cert/detail', { planApi: { get: async () => ({ data: bad }) } })
await h.init()
await h.setReady({ id: 'A' }, true)
assert.deepEqual(h.writes, [])
assert.equal(h.acting.value, false)
h.unmount()
})
test('cert parent ready idempotence, mismatched event, saved identity and query gate', async () => {
let permissionReads = 0,
reloads = 0,
opens = 0
const h = harness('plan-cert/detail', {
fetchGetMenuCodes: async () => {
permissionReads++
return { all: true }
}
})
h.detailRef.value = {
reload: () => {
reloads++
},
openContract: () => {
opens++
},
invalidateRecord() {}
}
h.route.query.contractId = 'contract'
await flush()
assert.equal(opens, 0)
await h.init()
await h.init()
assert.equal(permissionReads, 1)
await h.setReady({ id: 'OTHER' }, true)
assert.deepEqual(h.calls, [])
await h.saved('OTHER')
assert.equal(reloads, 0)
await h.saved('A')
assert.equal(reloads, 1)
h.unmount()
})
for (const reject of [false, true])
test(`cert permissions retry supersedes old ${reject ? 'failure' : 'success'}`, async () => {
const old = deferred()
let n = 0
const h = harness('plan-cert/detail', {
fetchGetMenuCodes: () => (++n === 1 ? old.promise : Promise.resolve({ all: false }))
})
const pending = h.init()
await h.loadPermissions()
if (reject) old.reject(new Error('old'))
else old.resolve({ all: true })
await pending
assert.equal(h.canReady.value, false)
assert.equal(h.permissionError.value, false)
assert.equal(h.permissionsLoaded.value, true)
h.unmount()
})
for (const transition of ['B', 'ABA', 'close-reopen', 'unmount', 'invalidate', 'permission'])
for (const reject of [false, true])
test(`upload ${transition}: stale save ${reject ? 'error' : 'success'}`, async () => {
@@ -688,90 +587,6 @@ for (const reject of [false, true])
assert.equal(h.plan.value.id, 'B')
h.unmount()
})
function recordChildKey(parent: any, component: string) {
const template = parse(
readFileSync(new URL('../src/views/m/pl/plan-cert/detail.vue', import.meta.url), 'utf8')
).descriptor.template!.content
const key = template.match(new RegExp(`<${component}\\b[^>]*:key="([^"]+)"`))?.[1]
assert.ok(key, `${component} must have a record-instance key`)
return new Function('state', `with (state) { return (${key}) }`)(vue.proxyRefs(parent))
}
for (const change of ['record', 'ABA', 'query'])
test(`cert upload real parent renderer ${change} before child props flush`, async () => {
const saved = deferred()
let child: any
const Child = vue.defineComponent({
props: ['modelValue', 'planId', 'permitted'],
emits: ['update:modelValue', 'success'],
setup(props, context) {
child = harness('plan-cert/CertUpload', {
defineProps: () => props,
defineEmits: () => context.emit,
defineExpose: context.expose,
onBeforeUnmount: vue.onBeforeUnmount,
planApi: { updateProductCerts: () => saved.promise }
})
return () => vue.h('upload')
}
})
const renderer = vue.createRenderer({
insert(el: any, parent: any) {
el.parent = parent
},
remove() {},
createElement: () => ({}),
createText: () => ({}),
createComment: () => ({}),
setText() {},
setElementText() {},
parentNode: (n: any) => n.parent,
nextSibling: () => null,
patchProp() {}
})
const template = parse(
readFileSync(new URL('../src/views/m/pl/plan-cert/detail.vue', import.meta.url), 'utf8')
).descriptor.template!.content
assert.match(template, /<CertUpload\b[^>]*ref="uploadRef"/)
const parent = harness('plan-cert/detail')
await parent.init()
parent.uploadOpen.value = true
let successes = 0
const App = vue.defineComponent({
setup: () => () =>
vue.h(Child, {
key: recordChildKey(parent, 'CertUpload'),
ref: parent.uploadRef,
planId: parent.id.value,
modelValue: parent.uploadOpen.value,
permitted: parent.canUpload.value,
'data-tab': parent.route.query.tab,
'onUpdate:modelValue': (open: boolean) => {
parent.uploadOpen.value = open
},
onSuccess: (id: string) => {
successes++
void parent.saved(id)
}
})
})
const app = renderer.createApp(App)
app.mount({})
await flush()
const oldChild = child,
pending = child.save()
saved.resolve()
if (change !== 'query') {
parent.route.params.id = 'B'
if (change === 'ABA') parent.route.params.id = 'A'
} else parent.route.query.tab = 'notes'
assert.equal(oldChild.props.planId, 'A', 'Vue child props have not flushed')
await pending
await flush()
assert.equal(successes, change !== 'query' ? 0 : 1)
assert.equal(oldChild.messages.length, change !== 'query' ? 0 : 1)
app.unmount()
parent.unmount()
})
for (const reject of [false, true])
test(`cert detail same-record retry isolates old loader ${reject ? 'error' : 'success/finally'}`, async () => {
const old = deferred(),
@@ -831,84 +646,6 @@ test('transport local tabs retain single-line 44px targets and bordered content
)
assert.match(style, /\.mp-wv-section\s*\{[^}]*min-width:\s*0[^}]*box-sizing:\s*border-box/)
})
for (const component of ['CertDetail', 'CertUpload'])
test(`${component}: real parent same-tick ABA remounts, unrelated query preserves instance`, async () => {
let child: any,
mounts = 0,
reads = 0
const Child = vue.defineComponent({
props: ['planId', 'modelValue', 'permitted'],
setup(props, context) {
mounts++
const read = async (id: string) => {
reads++
return { id, products: [{ id: 'p', isToxic: 1 }] }
}
child = harness(`plan-cert/${component}`, {
defineProps: () => props,
defineExpose: context.expose,
onBeforeUnmount: vue.onBeforeUnmount,
usePlPlanViewDetail: detailLoader(read, true),
planApi: { get: read }
})
return () => vue.h('child')
}
})
const renderer = vue.createRenderer({
insert(el: any, parent: any) {
el.parent = parent
},
remove() {},
createElement: () => ({}),
createText: () => ({}),
createComment: () => ({}),
setText() {},
setElementText() {},
parentNode: (n: any) => n.parent,
nextSibling: () => null,
patchProp() {}
})
const parent = harness('plan-cert/detail')
await parent.init()
parent.uploadOpen.value = true
const App = vue.defineComponent({
setup: () => () =>
vue.h(Child, {
key: recordChildKey(parent, component),
ref: component === 'CertDetail' ? parent.detailRef : parent.uploadRef,
planId: parent.id.value,
modelValue: parent.uploadOpen.value,
permitted: parent.canUpload.value,
'data-tab': parent.route.query.tab
})
})
const app = renderer.createApp(App)
app.mount({})
await flush()
assert.equal(child.plan.value.id, 'A')
const first = child
if (component === 'CertUpload') child.products.value[0].purchaseCertUrl = 'preserved'
parent.route.query.tab = 'info'
await flush()
assert.equal(child, first)
assert.equal(reads, 1)
assert.equal(mounts, 1)
if (component === 'CertUpload')
assert.equal(child.products.value[0].purchaseCertUrl, 'preserved')
parent.route.params.id = 'B'
parent.route.params.id = 'A'
await flush()
assert.equal(mounts, 2, 'an invalidated child must never be reused for same-tick ABA')
if (component === 'CertUpload') {
parent.openUpload({ id: 'A' })
await flush()
}
assert.equal(reads, 2)
assert.equal(child.plan.value.id, 'A')
assert.equal(child.loading.value, false)
app.unmount()
parent.unmount()
})
for (const reject of [false, true])
test(`cert detail reload resets copy busy while obsolete ${reject ? 'failure' : 'success'} preserves new copy`, async () => {
const old = deferred(),
@@ -967,59 +704,6 @@ test('plan detail identity switch invalidates old reads and closes every record
h.unmount()
})
for (const change of ['token', 'user', 'logout'])
test(`cert parent ${change} change invalidates children and pending ready confirmation`, async () => {
const user = vue.reactive({ accessToken: 'old', info: { id: 'old-user' } })
const confirm = deferred()
let permissions = 0,
invalidations = 0
const h = harness('plan-cert/detail', {
useUserStore: () => user,
showCrmConfirmDialog: () => confirm.promise,
fetchGetMenuCodes: async () => (++permissions === 1 ? { all: true } : { menuCodes: [] })
})
await h.init()
h.detailRef.value = h.uploadRef.value = { invalidateRecord: () => invalidations++ }
h.uploadOpen.value = true
const pending = h.setReady({ id: 'A' }, true)
await flush()
const epoch = h.recordEpoch.value
if (change === 'token') user.accessToken = 'new'
else if (change === 'user') user.info = { id: 'new-user' }
else user.accessToken = ''
assert.equal(h.uploadOpen.value, false)
assert.equal(h.canReady.value, false)
assert.ok(h.recordEpoch.value > epoch)
assert.ok(invalidations >= 2)
confirm.resolve()
await pending
await flush()
assert.deepEqual(h.writes, [])
assert.equal(h.canUpload.value, false)
assert.equal(permissions, change === 'logout' ? 1 : 2)
h.unmount()
})
test('cert parent old permission read cannot grant access after identity changes', async () => {
const user = vue.reactive({ accessToken: 'old', info: { id: 'user' } })
const old = deferred()
let reads = 0
const h = harness('plan-cert/detail', {
useUserStore: () => user,
fetchGetMenuCodes: () => (++reads === 1 ? old.promise : Promise.resolve({ menuCodes: [] }))
})
const init = h.init()
user.accessToken = 'new'
await flush()
old.resolve({ all: true })
await init
assert.equal(h.canUpload.value, false)
assert.equal(h.canReady.value, false)
assert.equal(h.canPrice.value, false)
assert.equal(reads, 2)
h.unmount()
})
test('cert detail requests mobile metadata once and passes that snapshot to the shared read loader', async () => {
const snapshot = { id: 'A', products: [], deliveries: [] }
const reads: any[] = [],
@@ -198,7 +198,7 @@ test('plan: detail to edit and back to list preserves search, pages, expansion a
env.close()
}
})
for (const file of ['plan', 'plan-cert'])
for (const file of ['plan'])
for (const result of ['success', 'failure', 'identity']) {
test(`${file}: return waits for expanded details before scroll and guards ${result}`, async () => {
const env = harness(file)
@@ -230,7 +230,7 @@ for (const file of ['plan', 'plan-cert'])
}
})
}
for (const file of ['plan', 'plan-cert']) {
for (const file of ['plan']) {
for (const failedPage of [1, 2])
test(`${file}: page ${failedPage} restoration failure retries via real page handler then consumes snapshot`, async () => {
const env = harness(file)
@@ -355,7 +355,7 @@ for (const file of ['plan', 'plan-cert']) {
})
}
for (const file of ['plan', 'plan-cert']) {
for (const file of ['plan']) {
test(`${file}: absent opt-in retains one-shot CRM default restoration`, async () => {
const env = harness(file)
try {
@@ -403,7 +403,7 @@ for (const file of ['plan', 'plan-cert']) {
}
}
for (const file of ['plan', 'plan-cert']) {
for (const file of ['plan']) {
test(`${file}: equivalent user info replacement preserves failed return snapshot`, async () => {
const env = harness(file)
try {
@@ -0,0 +1,56 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { parse } from '@vue/compiler-sfc'
import ts from 'typescript'
function harness() {
const script = parse(readFileSync(new URL('../src/components/business/pl-plan-archive-dialog/index.vue', import.meta.url), 'utf8')).descriptor.scriptSetup!.content
const ast = ts.createSourceFile('archive.ts', script, ts.ScriptTarget.Latest, true)
const methods = ast.statements.filter(n => ts.isFunctionDeclaration(n) && ['handleSubmit', 'submitArchiveWithOverageConfirm'].includes(n.name?.text || '')).map(n => n.getText(ast)).join('\n')
const calls: any[] = []
let resolveQuota: (value: any) => void = () => {}
let outcome: any = {}
const body = { products: [{ id: 'source-a', actualQty: 18 }] }
const state: any = {
props: { planId: 'a' }, innerVisible: { value: true }, submitLoading: { value: false },
canSubmitStopArchive: { value: true }, loadProductRows: { value: [] }, deliveryBlocks: { value: [] },
productRows: { value: [{ isToxic: 1 }] }, formData: { hasFreight: 0 }, plan: { value: {} },
isAdjustMode: { value: false }, quotaSettlementDialog: { value: { open: () => new Promise(resolve => { resolveQuota = resolve }) } },
syncSegmentBrandTotals() {}, syncMultiStopBrandActualQty() {}, validateStopArchiveRows() {}, validateSegmentBrandRows() {}, validatePlanLevelBrandTotals() {},
hasFeedbackAttachmentUploading: () => false, buildArchiveBody: () => body,
ElMessage: { warning() {}, success() {}, error() {} }, ElMessageBox: { alert: async () => {} },
tabsRef: { value: null }, emit() {}, pickBxData: (v: any) => v,
extractArchiveLineOverageDetail: () => null, applyArchiveOverageHighlight() {}, showArchiveLineOverageConfirm: async () => false,
planApi: { archive: async (id: string, data: any) => { calls.push({ id, data }); if(outcome instanceof Error) throw outcome; return outcome } }
}
const js = ts.transpileModule(`let archiveSession=0; let quotaArchiveAttempt={signature:'',id:''};${methods}; return {run:handleSubmit,submit:submitArchiveWithOverageConfirm,invalidate:()=>archiveSession++}`, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None } }).outputText
const actions = new Function(...Object.keys(state), js)(...Object.values(state))
return { state, calls, body, ...actions, resolveQuota: (v: any) => resolveQuota(v), outcome: (v: any) => { outcome=v } }
}
test('waiting quota response cannot submit a subsequently opened plan', async () => {
const h = harness(); const task = h.run()
assert.equal(h.state.submitLoading.value, true)
h.state.props.planId = 'b'; h.invalidate(); h.resolveQuota({ quotaManualActuals: [] })
await task
assert.deepEqual(h.calls, [])
})
test('quota wait preserves the clicked plan and frozen actual quantities', async () => {
const h = harness(); const task = h.run()
h.body.products[0].actualQty = 999
h.resolveQuota({ quotaManualActuals: [] }); await task
assert.equal(h.calls[0].id, 'a')
assert.equal(h.calls[0].data.products[0].actualQty, 18)
})
test('committed quota exception starts a new operation; unknown transport failure reuses operation', async () => {
const h = harness(); h.outcome({ quotaSettlementError: '余额不足' })
await assert.rejects(h.submit('a', h.body, () => true))
h.outcome({}); await h.submit('a', h.body, () => true)
assert.notEqual(h.calls[0].data.operationId, h.calls[1].data.operationId)
h.outcome(new Error('network')); await assert.rejects(h.submit('a', h.body, () => true))
h.outcome({}); await h.submit('a', h.body, () => true)
assert.equal(h.calls[2].data.operationId, h.calls[3].data.operationId)
})
@@ -0,0 +1,31 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { parse } from '@vue/compiler-sfc'
import ts from 'typescript'
function harness(arrange = false) {
const script = parse(readFileSync(new URL('../src/components/business/pl-plan-quota-dialog/index.vue', import.meta.url), 'utf8')).descriptor.scriptSetup!.content
const ast = ts.createSourceFile('quota.ts', script, ts.ScriptTarget.Latest, true)
const method = ast.statements.find(n => ts.isFunctionDeclaration(n) && n.name?.text === 'accept')!.getText(ast)
let finish!: () => void
const pending = new Promise<void>(r => { finish = r })
const callbacks: any[] = []
const state: any = {
plan: { value: { planId: 'a' } }, busy: { value: false }, visible: { value: true },
forArrange: { value: arrange }, forSupplement: { value: false },
result: { value: { status: 'available' } },
preview: () => pending, payload: () => ({ purchaseCertIds: ['p'] }),
planQuotaApi: { save: () => pending }, ElMessage: { success() {}, warning() {} }
}
const js = ts.transpileModule(`let epoch=0; let resolve=(v)=>callbacks.push(['old',v]);${method};return {run:accept,change:()=>{epoch++;plan.value={planId:'b'};busy.value=true;resolve=(v)=>callbacks.push(['new',v])}}`, {compilerOptions:{target:ts.ScriptTarget.ES2022}}).outputText
const actions = new Function(...Object.keys(state), 'callbacks', js)(...Object.values(state), callbacks)
return { ...actions, state, callbacks, finish }
}
for (const arrange of [false, true]) test(`stale ${arrange ? 'preview' : 'save'} cannot resolve or close the next session`, async () => {
const h = harness(arrange), task = h.run()
h.change(); h.finish(); await task
assert.deepEqual(h.callbacks, [])
assert.equal(h.state.visible.value, true)
assert.equal(h.state.busy.value, true)
})
@@ -0,0 +1,327 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { parse } from '@vue/compiler-sfc'
import ts from 'typescript'
import * as vue from 'vue'
const flush = async () => {
for (let i = 0; i < 20; i++) await Promise.resolve()
}
function deferred() {
let resolve!: (value?: any) => void
let reject!: (error: any) => void
const promise = new Promise<any>((a, b) => {
resolve = a
reject = b
})
return { promise, resolve, reject }
}
// Run production setup, including its real synchronous session watchers. Only I/O is substituted.
function pageHarness(kind: 'quota' | 'legal', props: any = { mobile: true }) {
const lists: any[] = [],
gets: any[] = [],
writes: any[] = [],
permissions: any[] = []
const events: string[] = [],
mounted: Function[] = [],
unmount: Function[] = []
const user = vue.reactive({ accessToken: 'a', getUserInfo: { tenantNo: 'a', roles: ['sales'] } })
const queue = (items: any[], params?: any) => {
const pending = deferred()
items.push({ ...pending, params })
return pending.promise
}
const env = {
...Object.fromEntries(
Object.entries(vue).filter(([key]) => /^[a-zA-Z_$][\w$]*$/.test(key) && key !== 'default')
),
useUserStore: () => user,
defineProps: () => props,
onMounted: (fn: Function) => mounted.push(fn),
onBeforeUnmount: (fn: Function) => unmount.push(fn),
request: { get: (url: string, options: any) => queue(lists, { url, ...options.params }) },
pickBxData: (value: any) => value,
legalDocumentApi: {
list: (params: any) => queue(lists, params),
get: (id: string) => queue(gets, id),
save: (id: string, products: any) => queue(writes, { id, products })
},
fetchGetMenuCodes: () => queue(permissions),
ElMessage: {
warning: (msg: string) => events.push(msg),
success: (msg: string) => events.push(msg)
}
}
const path =
kind === 'quota'
? '../src/views/pl/plan-quota/index.vue'
: '../src/views/m/pl/legal-use-document/index.vue'
const source = ts.createSourceFile(
'page.ts',
parse(readFileSync(new URL(path, import.meta.url), 'utf8')).descriptor.scriptSetup!.content,
ts.ScriptTarget.Latest,
true
)
const statements = source.statements.filter((s) => !ts.isImportDeclaration(s))
const names = statements.flatMap((s) =>
ts.isVariableStatement(s)
? s.declarationList.declarations
.filter((d) => ts.isIdentifier(d.name))
.map((d) => d.name.getText(source))
: ts.isFunctionDeclaration(s) && s.name
? [s.name.text]
: []
)
const code = ts.transpile(statements.map((s) => s.getText(source)).join('\n'), {
target: ts.ScriptTarget.ESNext
})
const scope = vue.effectScope()
const state = scope.run(() =>
new Function(...Object.keys(env), `${code}; return {${names.join(',')}}`)(...Object.values(env))
)
return {
state,
user,
lists,
gets,
writes,
permissions,
events,
mounted,
stop() {
unmount.forEach((fn) => fn())
scope.stop()
}
}
}
const response = (kind: string, id: string) =>
kind === 'quota' ? { list: [{ id }], total: 1 } : [{ id }]
const changeScope = (h: ReturnType<typeof pageHarness>, change: string) => {
if (change === 'token') h.user.accessToken = 'b'
if (change === 'tenant') h.user.getUserInfo.tenantNo = 'b'
if (change === 'roles') h.user.getUserInfo.roles = ['viewer']
}
for (const kind of ['quota', 'legal'] as const) {
test(`${kind}: real query sends keyword/status and latest response wins`, async () => {
const h = pageHarness(kind),
s = h.state
s.keyword.value = 'PLAN-A'
s.status.value = kind === 'quota' ? 'reserved' : 'pending'
const first = s.load()
assert.equal(h.lists[0].params.keyword, 'PLAN-A')
assert.equal(h.lists[0].params.status, s.status.value)
s.keyword.value = 'PLAN-B'
const second = s.load()
h.lists[1].resolve(response(kind, 'B'))
await second
h.lists[0].resolve(response(kind, 'A'))
await first
assert.deepEqual(s.rows.value, [{ id: 'B' }])
assert.equal(s.loading.value, false)
h.stop()
})
for (const change of ['token', 'tenant', 'roles']) {
test(`${kind}: ${change} clears private rows and rejects a pending old response`, async () => {
const h = pageHarness(kind),
s = h.state
if (kind === 'legal') {
const init = s.init()
h.permissions[0].resolve({ menuCodes: ['pl:plan-cert:upload'] })
await flush()
h.lists[0].resolve([{ id: 'private' }])
await init
} else s.rows.value = [{ id: 'private' }]
const old = s.load(),
oldRequest = h.lists.at(-1)
if (kind === 'legal') {
s.detail.value = { id: 'private' }
s.visible.value = true
}
changeScope(h, change)
assert.deepEqual(s.rows.value, [])
if (kind === 'legal') {
assert.equal(s.detail.value, undefined)
assert.equal(s.visible.value, false)
assert.equal(s.canUpload.value, false)
h.permissions.at(-1).resolve({ menuCodes: [] })
await flush()
}
const fresh = h.lists.at(-1)
assert.notEqual(fresh, oldRequest)
oldRequest.resolve(response(kind, 'private'))
await old
assert.deepEqual(s.rows.value, [])
assert.equal(s.loading.value, true, 'old finally cannot release current request')
fresh.resolve(response(kind, 'new-scope'))
await flush()
assert.deepEqual(s.rows.value, [{ id: 'new-scope' }])
h.stop()
})
}
test(`${kind}: unmount invalidates pending list response`, async () => {
const h = pageHarness(kind),
pending = h.state.load()
h.stop()
h.lists[0].resolve(response(kind, 'late'))
await pending
assert.deepEqual(h.state.rows.value, [])
})
}
test('legal: stale list failure cannot overwrite successful newer search', async () => {
const h = pageHarness('legal'),
s = h.state
const old = s.load(),
fresh = s.load()
h.lists[1].resolve([{ id: 'current' }])
await fresh
h.lists[0].reject(new Error('stale network error'))
await old
assert.equal(s.error.value, '')
assert.deepEqual(s.rows.value, [{ id: 'current' }])
h.stop()
})
test('legal: failed list can be retried and empty success is distinct from failure', async () => {
const h = pageHarness('legal'),
s = h.state
const failed = s.load()
h.lists[0].reject(new Error('offline'))
await failed
assert.ok(s.error.value)
assert.equal(s.loading.value, false)
const retry = s.load()
h.lists[1].resolve([])
await retry
assert.equal(s.error.value, '')
assert.deepEqual(s.rows.value, [])
h.stop()
})
test('legal: switching detail or closing rejects older detail response', async () => {
const h = pageHarness('legal'),
s = h.state
const first = s.open('A'),
second = s.open('B')
h.gets[1].resolve({ id: 'B' })
await second
h.gets[0].resolve({ id: 'A' })
await first
assert.equal(s.detail.value.id, 'B')
const third = s.open('C')
s.close(false)
h.gets[2].resolve({ id: 'C' })
await third
assert.equal(s.detail.value, undefined)
assert.equal(s.visible.value, false)
h.stop()
})
for (const change of ['token', 'tenant', 'roles']) {
test(`legal: ${change} invalidates permission, detail and save callbacks`, async () => {
const h = pageHarness('legal'),
s = h.state
const init = s.init(),
detail = s.open('private')
s.canUpload.value = true
s.detail.value = { id: 'private', products: [{ id: 'line', legalUseCertUrl: 'attachment' }] }
const save = s.save()
assert.deepEqual(h.writes[0].params, {
id: 'private',
products: [{ id: 'line', legalUseCertUrl: 'attachment' }]
})
changeScope(h, change)
h.permissions[0].resolve({ all: true })
await init
h.gets[0].resolve({ id: 'private' })
await detail
s.saving.value = true // another action in the new scope
h.writes[0].resolve()
await save
assert.equal(s.canUpload.value, false)
assert.equal(s.detail.value, undefined)
assert.equal(s.saving.value, true)
assert.deepEqual(h.events, [])
assert.equal(h.lists.length, 0)
h.stop()
})
}
test('legal: duplicate save and active uploads are guarded; session clears old uploader handles', async () => {
const h = pageHarness('legal'),
s = h.state
s.canUpload.value = true
s.detail.value = { id: 'A', products: [{ id: 'line', legalUseCertUrl: 'url' }] }
s.setUpload('line', { hasUploading: () => true })
await s.save()
assert.equal(h.writes.length, 0)
assert.equal(h.events.length, 1)
changeScope(h, 'tenant')
s.canUpload.value = true
s.detail.value = { id: 'B', products: [{ id: 'new-line', legalUseCertUrl: 'new-url' }] }
const save = s.save()
await s.save()
assert.equal(h.writes.length, 1)
h.writes[0].resolve()
await flush()
h.lists[0].resolve([])
await save
assert.equal(s.visible.value, false)
assert.equal(s.detail.value, undefined)
assert.equal(s.saving.value, false)
h.stop()
})
test('quota: finishing an old dialog after session change cannot reload the current list', async () => {
const h = pageHarness('quota'),
s = h.state,
result = deferred()
s.dialog.value = { open: () => result.promise, invalidateSession() {} }
s.open('A')
changeScope(h, 'tenant')
assert.equal(h.lists.length, 1)
result.resolve()
await flush()
assert.equal(h.lists.length, 1)
h.stop()
})
for (const kind of ['quota', 'legal'] as const) {
test(`${kind}: equivalent user snapshot does not clear state or restart requests`, () => {
const h = pageHarness(kind),
s = h.state
s.rows.value = [{ id: 'current' }]
h.user.getUserInfo = { tenantNo: 'a', roles: ['sales'] }
assert.deepEqual(s.rows.value, [{ id: 'current' }])
assert.equal(h.lists.length, 0)
assert.equal(h.permissions.length, 0)
h.stop()
})
}
test('quota: old mounted load cannot open a plan from the previous session', async () => {
const h = pageHarness('quota', { mobile: true, planId: 'private' }),
opened: string[] = []
h.state.dialog.value = {
open: (id: string) => {
opened.push(id)
return Promise.resolve()
},
invalidateSession() {}
}
const mount = h.mounted[0]()
changeScope(h, 'token')
h.lists[0].resolve({ list: [], total: 0 })
await mount
assert.deepEqual(opened, [])
h.stop()
})
test('legal: read-only permission prevents writes even when detail contains attachments', async () => {
const h = pageHarness('legal'),
s = h.state
const init = s.init()
h.permissions[0].resolve({ menuCodes: [] })
await flush()
h.lists[0].resolve([])
await init
const detail = s.open('A')
h.gets[0].resolve({ id: 'A', products: [{ id: 'line', legalUseCertUrl: 'url' }] })
await detail
assert.equal(s.visible.value, true)
await s.save()
assert.equal(h.writes.length, 0)
h.stop()
})
@@ -0,0 +1,39 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { parse } from '@vue/compiler-sfc'
import ts from 'typescript'
function harness() {
const script = parse(readFileSync(new URL('../src/components/business/pl-quota-settlement-dialog/index.vue', import.meta.url), 'utf8')).descriptor.scriptSetup!.content
const ast = ts.createSourceFile('settlement.ts', script, ts.ScriptTarget.Latest, true)
const methods = ast.statements.filter(n => ts.isFunctionDeclaration(n) && ['accept','chooseSupplement'].includes(n.name?.text || '')).map(n=>n.getText(ast)).join('\n')
const results: any[] = [], warnings: string[] = []
let finish!: (v:any)=>void
const state:any = {
allowSupplement:{value:true}, choosing:{value:false}, supplementConfiguration:{value:undefined},
targetPlanId:{value:'a'}, rows:{value:[]}, adjustment:{value:false}, reason:{value:'超量'},
sourceNote:{value:'磅单'}, evidenceUrls:{value:''}, attachments:{value:undefined}, visible:{value:true},
supplementDialog:{value:{open:()=>new Promise(r=>{finish=r})}},
ElMessage:{warning:(m:string)=>warnings.push(m)}
}
const js=ts.transpileModule(`let epoch=0; let resolve=(v)=>results.push(v);${methods};return {accept,choose:chooseSupplement,invalidate:()=>epoch++}`,{compilerOptions:{target:ts.ScriptTarget.ES2022}}).outputText
const actions=new Function(...Object.keys(state),'results',js)(...Object.values(state),results)
return {...actions,state,results,warnings,finish:(v:any)=>finish(v)}
}
test('supplement requires candidates and provenance before forwarding explicit intent',()=>{
const h=harness();h.accept();assert.equal(h.results.length,0)
h.state.supplementConfiguration.value={purchaseCertIds:['g'],transportCertIds:['y'],conditions:[{certId:'g'}]}
h.state.sourceNote.value='';h.accept();assert.equal(h.results.length,0)
h.state.sourceNote.value='磅单';h.accept()
assert.equal(h.results[0].quotaAllowSupplement,true)
assert.deepEqual(h.results[0].quotaConfiguration,h.state.supplementConfiguration.value)
})
test('late supplementary selection cannot update another settlement session',async()=>{
const h=harness(),task=h.choose();h.invalidate();h.finish({purchaseCertIds:['old']});await task
assert.equal(h.state.supplementConfiguration.value,undefined)
})
test('turning off supplement does not forward stale extra candidates',()=>{
const h=harness();h.state.allowSupplement.value=false;h.state.supplementConfiguration.value={purchaseCertIds:['old']};h.accept()
assert.equal(h.results[0].quotaAllowSupplement,false)
assert.equal(h.results[0].quotaConfiguration,undefined)
})