Files
shanxia2.0/web-admin/scripts/mobile-contract-detail-review.test.ts
T

285 lines
9.8 KiB
TypeScript

import assert from 'node:assert/strict'
import test from 'node:test'
import { readFileSync } from 'node:fs'
import { parse } from '@vue/compiler-sfc'
import ts from 'typescript'
import * as vue from 'vue'
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 }
}
const flush = async () => {
for (let i = 0; i < 20; i++) await Promise.resolve()
}
function harness(page: string, overrides: Record<string, any> = {}) {
const content = parse(
readFileSync(new URL(`../src/views/m/crm/${page}.vue`, import.meta.url), 'utf8')
).descriptor.scriptSetup!.content
const ast = ts.createSourceFile('page.ts', content, ts.ScriptTarget.Latest, true)
const route = vue.reactive({
name: 'MpCrmContractDetail',
params: { id: 'A' },
query: {} as Record<string, string>,
fullPath: '/A',
get path() {
return `/m/crm/contract/${this.params.id}`
}
})
const unmounts: Array<() => void> = []
const writes: any[] = [],
pushes: any[] = [],
calls: string[] = []
const bindings: Record<string, any> = Object.fromEntries(
Object.entries(vue).filter(([key]) => /^[a-zA-Z_$][\w$]*$/.test(key) && key !== 'default')
)
for (const s of ast.statements) {
if (!ts.isImportDeclaration(s)) continue
const clause = s.importClause
if (clause?.name) bindings[clause.name.text] = () => undefined
if (clause?.namedBindings && ts.isNamedImports(clause.namedBindings))
for (const item of clause.namedBindings.elements) bindings[item.name.text] = () => undefined
}
const read = async (arg: any) => {
const id = typeof arg === 'string' ? arg : arg.id
calls.push(id)
return { data: { id, status: 'draft', products: [], productList: [] } }
}
Object.assign(bindings, {
useRoute: () => route,
useRouter: () => ({
push: (v: any) => pushes.push(v),
replace: async (v: any) => {
if (typeof v === 'object') route.query = v.query
else pushes.push(v)
}
}),
useUserStore: () => ({ info: { id: 'user' } }),
useDictStore: () => ({ getLabel: () => '', getTagType: () => '' }),
onMounted() {},
onUnmounted: (fn: any) => unmounts.push(fn),
onBeforeUnmount: (fn: any) => unmounts.push(fn),
getContract: read,
getQuote: read,
fetchCustomerDetail: read,
fetchProductDetail: read,
listContractApproval: async () => ({ data: [] }),
listApproval: async () => ({ data: [] }),
listQuoteProduct: async () => ({ rows: [] }),
fetchCategoryTree: async () => ({ data: [] }),
buildPathLabelMapFromTreeRoots: () => new Map(),
unwrapData: (v: any) => v.data ?? v,
customerForm: (v: any) => v,
canDeleteByCreator: () => true,
isContractEditableStatus: () => true,
ElMessage: { warning() {}, error() {}, success() {} },
showCrmConfirmDialog: async () => {},
showProductConfirmDialog: async () => {},
fetchDeleteCustomer: async (v: any) => writes.push(v),
fetchDeleteProduct: async (v: any) => writes.push(v),
delContract: async (v: any) => writes.push(v),
delQuote: async (v: any) => writes.push(v),
fetchUpdateProductStatus: async (v: any) => writes.push(v),
defineExpose() {},
defineProps: () => vue.reactive({ embedded: false, recordId: undefined }),
withDefaults: (props: any) => props,
defineEmits:
() =>
(...v: any[]) =>
pushes.push(v),
fetchIntoPool: async (v: any) => writes.push(v),
document: { querySelector: () => null },
window: { removeEventListener() {}, scrollTo() {} },
...overrides
})
const statements = ast.statements.filter((s) => !ts.isImportDeclaration(s))
const names = statements.flatMap((s) =>
ts.isFunctionDeclaration(s)
? [s.name!.text]
: ts.isVariableStatement(s)
? s.declarationList.declarations.map((d) => d.name.getText(ast))
: []
)
const code = ts.transpile(statements.map((s) => s.getText(ast)).join('\n'), {
target: ts.ScriptTarget.ESNext
})
const scope = vue.effectScope()
const api: any = scope.run(() =>
new Function(...Object.keys(bindings), `${code};return {${names.join(',')}}`)(
...Object.values(bindings)
)
)
return {
...api,
route,
calls,
writes,
pushes,
ready: api.onShellReady || api.onReady,
record: api.contract || api.quote || api.customer || api.detail,
unmount: () => {
unmounts.forEach((fn) => fn())
scope.stop()
}
}
}
const pendingNode = {
id: 'approval',
step: 1,
approverId: 'user',
approvalResult: 'pending',
isCurrent: 1
}
for (const opinion of ['', ' ']) {
test(`contract approval blocks cleared opinion (${JSON.stringify(opinion)}) before confirmation or request`, async () => {
const writes: any[] = [],
warnings: string[] = []
let confirmations = 0
const h = harness('contract/detail', {
getContract: async () => ({ data: { id: 'A', status: 'approving', canApprove: true } }),
listContractApproval: async () => ({ data: [pendingNode] }),
approveContract: async (...args: any[]) => writes.push(args),
showCrmConfirmDialog: async () => {
confirmations++
},
ElMessage: { warning: (message: string) => warnings.push(message), error() {}, success() {} }
})
h.route.query.from = 'approve'
h.ready()
await flush()
await h.runAction('approve')
assert.equal(h.approveOpinion.value, '审批通过')
h.approveOpinion.value = opinion
await h.confirmApprove()
assert.deepEqual(writes, [])
assert.equal(confirmations, 0)
assert.equal(warnings.length, 1)
assert.equal(h.approveOpen.value, true)
h.unmount()
})
}
test('contract approval sends the trimmed required opinion with the contract ID and prevents duplicate writes', async () => {
const writes: any[] = []
const pendingWrite = deferred()
const h = harness('contract/detail', {
getContract: async () => ({ data: { id: 'A', status: 'approving', canApprove: true } }),
listContractApproval: async () => ({ data: [pendingNode] }),
approveContract: (...args: any[]) => {
writes.push(args)
return pendingWrite.promise
}
})
h.route.query.from = 'approve'
h.ready()
await flush()
await h.runAction('approve')
h.approveOpinion.value = ' 同意合同条款 '
const request = h.confirmApprove()
await flush()
await h.confirmApprove()
assert.deepEqual(writes, [['A', '同意合同条款']])
pendingWrite.resolve()
await request
assert.equal(h.approveOpen.value, false)
assert.equal(h.acting.value, false)
h.unmount()
})
test('contract detail respects explicit approval denial while accepting omitted legacy flag', async () => {
const h = harness('contract/detail', {
getContract: async () => ({ data: { id: 'A', status: 'approving', canApprove: false } }),
listContractApproval: async () => ({ data: [pendingNode] })
})
h.ready()
await flush()
assert.equal(h.pendingApproval.value, null)
delete h.contract.value.canApprove
assert.equal(h.pendingApproval.value?.id, 'approval')
h.unmount()
})
test('contract approval history failure is visible and retry preserves main detail', async () => {
let attempts = 0
const h = harness('contract/detail', {
listContractApproval: async () => {
if (++attempts === 1) throw new Error('history offline')
return { data: [pendingNode] }
}
})
h.ready()
await flush()
assert.equal(h.contract.value.id, 'A')
assert.equal(h.approvalError?.value, 'history offline')
await h.retryApprovalHistory()
assert.equal(h.approvalError.value, '')
assert.equal(h.approvalList.value[0].id, 'approval')
assert.deepEqual(h.calls, ['A'])
h.unmount()
})
test('contract identity change discards pending history and closes open actions before reloading', async () => {
const history = deferred()
const user = vue.reactive({ accessToken: 'old', info: { id: 'user' } })
let reads = 0
const h = harness('contract/detail', {
useUserStore: () => user,
listContractApproval: () => (++reads === 1 ? history.promise : Promise.resolve({ data: [] }))
})
h.ready()
await flush()
h.approveOpen.value = true
h.moreOpen.value = true
user.accessToken = 'new'
assert.equal(h.contract.value, null)
assert.equal(h.approveOpen.value, false)
assert.equal(h.moreOpen.value, false)
await flush()
history.resolve({ data: [pendingNode] })
await flush()
assert.deepEqual(h.approvalList.value, [])
assert.equal(reads, 2)
h.unmount()
})
test('approval retry is single flight and its stale failure cannot leak into another contract', async () => {
const retry = deferred()
let reads = 0
const h = harness('contract/detail', {
listContractApproval: () => {
reads++
if (reads === 1) return Promise.reject(new Error('offline'))
if (reads === 2) return retry.promise
return Promise.resolve({ data: [{ id: 'B' }] })
}
})
h.ready()
await flush()
const request = h.retryApprovalHistory()
await h.retryApprovalHistory()
assert.equal(reads, 2)
h.route.params.id = 'B'
await flush()
retry.reject(new Error('old failed'))
await request
assert.equal(h.approvalError.value, '')
assert.equal(h.approvalList.value[0].id, 'B')
h.unmount()
})
for (const change of ['identity', 'record']) {
test(`contract ${change} switch closes execution, completion and reason sheets immediately`, async () => {
const user = vue.reactive({ accessToken: 'old', info: { id: 'user' } })
const h = harness('contract/detail', { useUserStore: () => user })
h.ready()
await flush()
for (const key of ['executeOpen', 'completeOpen', 'voidReasonPickerOpen']) h[key].value = true
if (change === 'identity') user.accessToken = 'new'
else h.route.params.id = 'B'
for (const key of ['executeOpen', 'completeOpen', 'voidReasonPickerOpen'])
assert.equal(h[key].value, false, key)
await flush()
h.unmount()
})
}