import assert from 'node:assert/strict' import test from 'node:test' import { readFileSync } from 'node:fs' import { parse } from '@vue/compiler-sfc' import * as Vue from 'vue' import ts from 'typescript' import { findPendingQuoteApproval } from '../src/utils/mp-crm-quote-card' function deferred() { let resolve!: (value?: any) => void const promise = new Promise((r) => { resolve = r }) return { promise, resolve } } async function flush() { for (let i = 0; i < 30; i++) await Promise.resolve() } function harness(overrides: Record = {}) { const source = readFileSync( new URL('../src/views/m/crm/quote/detail.vue', import.meta.url), 'utf8' ) const ast = ts.createSourceFile( 'detail.ts', parse(source).descriptor.scriptSetup!.content, ts.ScriptTarget.Latest, true ) const statements = ast.statements.filter((s) => !ts.isImportDeclaration(s)) const names = statements.flatMap((s) => ts.isFunctionDeclaration(s) && s.name ? [s.name.text] : ts.isVariableStatement(s) ? s.declarationList.declarations.map((d) => d.name.getText(ast)) : [] ) const route = Vue.reactive({ name: 'MpCrmQuoteDetail', path: '/m/crm/quote/Q1', params: { id: 'Q1' }, query: { from: 'approve' } as Record }) const user = Vue.reactive({ accessToken: 'token', info: { id: 'user', tenantNo: 'T1' } }) const writes: any[] = [] const unmounts: (() => void)[] = [] const bindings: Record = { ...Vue } for (const s of ast.statements) if (ts.isImportDeclaration(s)) { if (s.importClause?.name) bindings[s.importClause.name.text] = () => {} const n = s.importClause?.namedBindings if (n && ts.isNamedImports(n)) for (const e of n.elements) bindings[e.name.text] = () => {} } Object.assign( bindings, { useRoute: () => route, useRouter: () => ({ push: () => {}, replace: async ({ query }: any) => { route.query = query } }), useUserStore: () => user, useDictStore: () => ({ getLabel: (_: any, value: any) => value, getTagType: () => 'info' }), getQuote: async (id: string) => ({ data: { id, status: 'submitted', productList: [] } }), listApproval: async () => ({ data: [{ id: 'approval-1', step: 1, approverId: 'user', approvalResult: 'pending' }] }), fetchCategoryTree: async () => [], buildPathLabelMapFromTreeRoots: () => new Map(), listQuoteProduct: async () => ({ rows: [] }), showCrmConfirmDialog: async () => {}, approveQuote: async (q: any) => writes.push(q), findPendingQuoteApproval, onUnmounted: () => {}, onMounted: () => {}, onBeforeUnmount: (fn: () => void) => unmounts.push(fn), document: { querySelector: () => null }, window: { removeEventListener: () => {} } }, overrides ) for (const key of Object.keys(bindings)) if (!/^[a-zA-Z_$][\w$]*$/.test(key) || key === 'default') delete bindings[key] const scope = Vue.effectScope() const code = ts.transpile(statements.map((s) => s.getText(ast)).join('\n'), { target: ts.ScriptTarget.ESNext }) const api = scope.run(() => new Function(...Object.keys(bindings), `${code};return {${names.join(',')}}`)( ...Object.values(bindings) ) ) return { api, route, user, writes, source, stop: () => { unmounts.forEach((fn) => fn()) scope.stop() } } } test('detail denies explicit false permission but supports omitted detail flags with current-step approval', async () => { for (const flag of [false, undefined, true]) { const h = harness({ getQuote: async (id: string) => ({ data: { id, status: 'submitted', canApprove: flag } }) }) try { h.api.onShellReady() await flush() assert.equal( h.api.fabActions.value.some((a: any) => a.key === 'approve'), flag !== false ) await h.api.runAction('approve') await h.api.confirmApprove() assert.equal(h.writes.length, flag === false ? 0 : 1) } finally { h.stop() } } }) test('identity change clears detail immediately and cancels an open approval confirmation', async () => { const confirmation = deferred() const h = harness({ showCrmConfirmDialog: () => confirmation.promise }) try { h.api.onShellReady() await flush() await h.api.runAction('approve') const pending = h.api.confirmApprove() h.user.info.tenantNo = 'T2' assert.equal(h.api.quote.value, null) assert.equal(h.api.approveOpen.value, false) confirmation.resolve() await pending assert.deepEqual(h.writes, []) } finally { h.stop() } }) test('approval history error is retryable and a late history response cannot cross identity changes', async () => { let failing = true const history = deferred() let pendingRead = false const h = harness({ listApproval: async () => { if (failing) throw new Error('审批历史离线') if (pendingRead) return history.promise return { data: [{ id: 'node', step: 1, approverId: 'user', approvalResult: 'pending' }] } } }) try { h.api.onShellReady() await flush() assert.ok(h.api.approvalError, 'history must have a distinct error state') assert.equal(h.api.approvalError.value, '审批历史离线') assert.equal(h.api.pendingApproval.value, null) failing = false await h.api.retryApprovalHistory() assert.equal(h.api.approvalError.value, '') assert.equal(h.api.pendingApproval.value.id, 'node') pendingRead = true const retry = h.api.retryApprovalHistory() h.user.accessToken = '' history.resolve({ data: [{ id: 'stale', approverId: 'user', approvalResult: 'pending' }] }) await retry assert.deepEqual(h.api.approvalList.value, []) } finally { h.stop() } })