feat(pl): 实现合同产品用证与额度结转以支持购买证和运输证全流程管理。
This commit is contained in:
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, '送货乙')
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user