mobile-contract-detail-review.test.ts 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  1. import assert from 'node:assert/strict'
  2. import test from 'node:test'
  3. import { readFileSync } from 'node:fs'
  4. import { parse } from '@vue/compiler-sfc'
  5. import ts from 'typescript'
  6. import * as vue from 'vue'
  7. function deferred() {
  8. let resolve!: (value?: any) => void
  9. let reject!: (error: any) => void
  10. const promise = new Promise<any>((a, b) => {
  11. resolve = a
  12. reject = b
  13. })
  14. return { promise, resolve, reject }
  15. }
  16. const flush = async () => {
  17. for (let i = 0; i < 20; i++) await Promise.resolve()
  18. }
  19. function harness(page: string, overrides: Record<string, any> = {}) {
  20. const content = parse(
  21. readFileSync(new URL(`../src/views/m/crm/${page}.vue`, import.meta.url), 'utf8')
  22. ).descriptor.scriptSetup!.content
  23. const ast = ts.createSourceFile('page.ts', content, ts.ScriptTarget.Latest, true)
  24. const route = vue.reactive({
  25. name: 'MpCrmContractDetail',
  26. params: { id: 'A' },
  27. query: {} as Record<string, string>,
  28. fullPath: '/A',
  29. get path() {
  30. return `/m/crm/contract/${this.params.id}`
  31. }
  32. })
  33. const unmounts: Array<() => void> = []
  34. const writes: any[] = [],
  35. pushes: any[] = [],
  36. calls: string[] = []
  37. const bindings: Record<string, any> = Object.fromEntries(
  38. Object.entries(vue).filter(([key]) => /^[a-zA-Z_$][\w$]*$/.test(key) && key !== 'default')
  39. )
  40. for (const s of ast.statements) {
  41. if (!ts.isImportDeclaration(s)) continue
  42. const clause = s.importClause
  43. if (clause?.name) bindings[clause.name.text] = () => undefined
  44. if (clause?.namedBindings && ts.isNamedImports(clause.namedBindings))
  45. for (const item of clause.namedBindings.elements) bindings[item.name.text] = () => undefined
  46. }
  47. const read = async (arg: any) => {
  48. const id = typeof arg === 'string' ? arg : arg.id
  49. calls.push(id)
  50. return { data: { id, status: 'draft', products: [], productList: [] } }
  51. }
  52. Object.assign(bindings, {
  53. useRoute: () => route,
  54. useRouter: () => ({
  55. push: (v: any) => pushes.push(v),
  56. replace: async (v: any) => {
  57. if (typeof v === 'object') route.query = v.query
  58. else pushes.push(v)
  59. }
  60. }),
  61. useUserStore: () => ({ info: { id: 'user' } }),
  62. useDictStore: () => ({ getLabel: () => '', getTagType: () => '' }),
  63. onMounted() {},
  64. onUnmounted: (fn: any) => unmounts.push(fn),
  65. onBeforeUnmount: (fn: any) => unmounts.push(fn),
  66. getContract: read,
  67. getQuote: read,
  68. fetchCustomerDetail: read,
  69. fetchProductDetail: read,
  70. listContractApproval: async () => ({ data: [] }),
  71. listApproval: async () => ({ data: [] }),
  72. listQuoteProduct: async () => ({ rows: [] }),
  73. fetchCategoryTree: async () => ({ data: [] }),
  74. buildPathLabelMapFromTreeRoots: () => new Map(),
  75. unwrapData: (v: any) => v.data ?? v,
  76. customerForm: (v: any) => v,
  77. canDeleteByCreator: () => true,
  78. isContractEditableStatus: () => true,
  79. ElMessage: { warning() {}, error() {}, success() {} },
  80. showCrmConfirmDialog: async () => {},
  81. showProductConfirmDialog: async () => {},
  82. fetchDeleteCustomer: async (v: any) => writes.push(v),
  83. fetchDeleteProduct: async (v: any) => writes.push(v),
  84. delContract: async (v: any) => writes.push(v),
  85. delQuote: async (v: any) => writes.push(v),
  86. fetchUpdateProductStatus: async (v: any) => writes.push(v),
  87. defineExpose() {},
  88. defineProps: () => vue.reactive({ embedded: false, recordId: undefined }),
  89. withDefaults: (props: any) => props,
  90. defineEmits:
  91. () =>
  92. (...v: any[]) =>
  93. pushes.push(v),
  94. fetchIntoPool: async (v: any) => writes.push(v),
  95. document: { querySelector: () => null },
  96. window: { removeEventListener() {}, scrollTo() {} },
  97. ...overrides
  98. })
  99. const statements = ast.statements.filter((s) => !ts.isImportDeclaration(s))
  100. const names = statements.flatMap((s) =>
  101. ts.isFunctionDeclaration(s)
  102. ? [s.name!.text]
  103. : ts.isVariableStatement(s)
  104. ? s.declarationList.declarations.map((d) => d.name.getText(ast))
  105. : []
  106. )
  107. const code = ts.transpile(statements.map((s) => s.getText(ast)).join('\n'), {
  108. target: ts.ScriptTarget.ESNext
  109. })
  110. const scope = vue.effectScope()
  111. const api: any = scope.run(() =>
  112. new Function(...Object.keys(bindings), `${code};return {${names.join(',')}}`)(
  113. ...Object.values(bindings)
  114. )
  115. )
  116. return {
  117. ...api,
  118. route,
  119. calls,
  120. writes,
  121. pushes,
  122. ready: api.onShellReady || api.onReady,
  123. record: api.contract || api.quote || api.customer || api.detail,
  124. unmount: () => {
  125. unmounts.forEach((fn) => fn())
  126. scope.stop()
  127. }
  128. }
  129. }
  130. const pendingNode = {
  131. id: 'approval',
  132. step: 1,
  133. approverId: 'user',
  134. approvalResult: 'pending',
  135. isCurrent: 1
  136. }
  137. for (const opinion of ['', ' ']) {
  138. test(`contract approval blocks cleared opinion (${JSON.stringify(opinion)}) before confirmation or request`, async () => {
  139. const writes: any[] = [],
  140. warnings: string[] = []
  141. let confirmations = 0
  142. const h = harness('contract/detail', {
  143. getContract: async () => ({ data: { id: 'A', status: 'approving', canApprove: true } }),
  144. listContractApproval: async () => ({ data: [pendingNode] }),
  145. approveContract: async (...args: any[]) => writes.push(args),
  146. showCrmConfirmDialog: async () => {
  147. confirmations++
  148. },
  149. ElMessage: { warning: (message: string) => warnings.push(message), error() {}, success() {} }
  150. })
  151. h.route.query.from = 'approve'
  152. h.ready()
  153. await flush()
  154. await h.runAction('approve')
  155. assert.equal(h.approveOpinion.value, '审批通过')
  156. h.approveOpinion.value = opinion
  157. await h.confirmApprove()
  158. assert.deepEqual(writes, [])
  159. assert.equal(confirmations, 0)
  160. assert.equal(warnings.length, 1)
  161. assert.equal(h.approveOpen.value, true)
  162. h.unmount()
  163. })
  164. }
  165. test('contract approval sends the trimmed required opinion with the contract ID and prevents duplicate writes', async () => {
  166. const writes: any[] = []
  167. const pendingWrite = deferred()
  168. const h = harness('contract/detail', {
  169. getContract: async () => ({ data: { id: 'A', status: 'approving', canApprove: true } }),
  170. listContractApproval: async () => ({ data: [pendingNode] }),
  171. approveContract: (...args: any[]) => {
  172. writes.push(args)
  173. return pendingWrite.promise
  174. }
  175. })
  176. h.route.query.from = 'approve'
  177. h.ready()
  178. await flush()
  179. await h.runAction('approve')
  180. h.approveOpinion.value = ' 同意合同条款 '
  181. const request = h.confirmApprove()
  182. await flush()
  183. await h.confirmApprove()
  184. assert.deepEqual(writes, [['A', '同意合同条款']])
  185. pendingWrite.resolve()
  186. await request
  187. assert.equal(h.approveOpen.value, false)
  188. assert.equal(h.acting.value, false)
  189. h.unmount()
  190. })
  191. test('contract detail respects explicit approval denial while accepting omitted legacy flag', async () => {
  192. const h = harness('contract/detail', {
  193. getContract: async () => ({ data: { id: 'A', status: 'approving', canApprove: false } }),
  194. listContractApproval: async () => ({ data: [pendingNode] })
  195. })
  196. h.ready()
  197. await flush()
  198. assert.equal(h.pendingApproval.value, null)
  199. delete h.contract.value.canApprove
  200. assert.equal(h.pendingApproval.value?.id, 'approval')
  201. h.unmount()
  202. })
  203. test('contract approval history failure is visible and retry preserves main detail', async () => {
  204. let attempts = 0
  205. const h = harness('contract/detail', {
  206. listContractApproval: async () => {
  207. if (++attempts === 1) throw new Error('history offline')
  208. return { data: [pendingNode] }
  209. }
  210. })
  211. h.ready()
  212. await flush()
  213. assert.equal(h.contract.value.id, 'A')
  214. assert.equal(h.approvalError?.value, 'history offline')
  215. await h.retryApprovalHistory()
  216. assert.equal(h.approvalError.value, '')
  217. assert.equal(h.approvalList.value[0].id, 'approval')
  218. assert.deepEqual(h.calls, ['A'])
  219. h.unmount()
  220. })
  221. test('contract identity change discards pending history and closes open actions before reloading', async () => {
  222. const history = deferred()
  223. const user = vue.reactive({ accessToken: 'old', info: { id: 'user' } })
  224. let reads = 0
  225. const h = harness('contract/detail', {
  226. useUserStore: () => user,
  227. listContractApproval: () => (++reads === 1 ? history.promise : Promise.resolve({ data: [] }))
  228. })
  229. h.ready()
  230. await flush()
  231. h.approveOpen.value = true
  232. h.moreOpen.value = true
  233. user.accessToken = 'new'
  234. assert.equal(h.contract.value, null)
  235. assert.equal(h.approveOpen.value, false)
  236. assert.equal(h.moreOpen.value, false)
  237. await flush()
  238. history.resolve({ data: [pendingNode] })
  239. await flush()
  240. assert.deepEqual(h.approvalList.value, [])
  241. assert.equal(reads, 2)
  242. h.unmount()
  243. })
  244. test('approval retry is single flight and its stale failure cannot leak into another contract', async () => {
  245. const retry = deferred()
  246. let reads = 0
  247. const h = harness('contract/detail', {
  248. listContractApproval: () => {
  249. reads++
  250. if (reads === 1) return Promise.reject(new Error('offline'))
  251. if (reads === 2) return retry.promise
  252. return Promise.resolve({ data: [{ id: 'B' }] })
  253. }
  254. })
  255. h.ready()
  256. await flush()
  257. const request = h.retryApprovalHistory()
  258. await h.retryApprovalHistory()
  259. assert.equal(reads, 2)
  260. h.route.params.id = 'B'
  261. await flush()
  262. retry.reject(new Error('old failed'))
  263. await request
  264. assert.equal(h.approvalError.value, '')
  265. assert.equal(h.approvalList.value[0].id, 'B')
  266. h.unmount()
  267. })
  268. for (const change of ['identity', 'record']) {
  269. test(`contract ${change} switch closes execution, completion and reason sheets immediately`, async () => {
  270. const user = vue.reactive({ accessToken: 'old', info: { id: 'user' } })
  271. const h = harness('contract/detail', { useUserStore: () => user })
  272. h.ready()
  273. await flush()
  274. for (const key of ['executeOpen', 'completeOpen', 'voidReasonPickerOpen']) h[key].value = true
  275. if (change === 'identity') user.accessToken = 'new'
  276. else h.route.params.id = 'B'
  277. for (const key of ['executeOpen', 'completeOpen', 'voidReasonPickerOpen'])
  278. assert.equal(h[key].value, false, key)
  279. await flush()
  280. h.unmount()
  281. })
  282. }