285 lines
9.8 KiB
TypeScript
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()
|
|
})
|
|
}
|