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((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 = {}) { 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, 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 = 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() }) }