| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174 |
- 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<any>((r) => {
- resolve = r
- })
- return { promise, resolve }
- }
- async function flush() {
- for (let i = 0; i < 30; i++) await Promise.resolve()
- }
- function harness(overrides: Record<string, any> = {}) {
- 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<string, string>
- })
- const user = Vue.reactive({ accessToken: 'token', info: { id: 'user', tenantNo: 'T1' } })
- const writes: any[] = []
- const unmounts: (() => void)[] = []
- const bindings: Record<string, any> = { ...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()
- }
- })
|