| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284 |
- 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()
- })
- }
|