mobile-crm-approvers.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433
  1. import assert from 'node:assert/strict'
  2. import test from 'node:test'
  3. import { readFileSync } from 'node:fs'
  4. import * as vue from 'vue'
  5. import { parse } from '@vue/compiler-sfc'
  6. import ts from 'typescript'
  7. import { createContractPicker } from '../src/views/m/crm/contract/mobileFormState'
  8. const pages = ['contract/detail', 'quote/detail']
  9. const users = [
  10. { value: 'A', nickName: '张三', userName: 'Alice', userNo: 'NO001' },
  11. { value: 'B', nickName: '李四', userName: 'Bob', userNo: 'NO002' },
  12. { value: 'C', nickName: '王五', userName: 'Carol', userNo: 'NO003' }
  13. ]
  14. function source(page: string) {
  15. return parse(readFileSync(new URL(`../src/views/m/crm/${page}.vue`, import.meta.url), 'utf8'))
  16. .descriptor
  17. }
  18. function evaluate(page: string, names: string[], bindings: any, watchers = false) {
  19. const ast = ts.createSourceFile(
  20. 'page.ts',
  21. source(page).scriptSetup!.content,
  22. ts.ScriptTarget.Latest,
  23. true
  24. )
  25. const statements = names.map((name) => {
  26. const node = ast.statements.find(
  27. (n) =>
  28. (ts.isFunctionDeclaration(n) && n.name?.text === name) ||
  29. (ts.isVariableStatement(n) &&
  30. n.declarationList.declarations.some((d) => d.name.getText(ast) === name))
  31. )
  32. assert.ok(node, `${page}: production declaration ${name}`)
  33. return node.getText(ast)
  34. })
  35. if (watchers)
  36. statements.push(
  37. ...ast.statements
  38. .filter(
  39. (n) =>
  40. ts.isExpressionStatement(n) &&
  41. /^(watch\(|onBeforeUnmount\()/.test(n.getText(ast)) &&
  42. /submitOpen|closeSubmitSession|closeRecordActions|resetRecord/.test(n.getText(ast))
  43. )
  44. .map((n) => n.getText(ast))
  45. )
  46. const code = ts.transpile(statements.join('\n'), { target: ts.ScriptTarget.ESNext })
  47. return new Function(...Object.keys(bindings), code + ';return {' + names.join(',') + '}')(
  48. ...Object.values(bindings)
  49. )
  50. }
  51. function deferred() {
  52. let resolve!: (x: any) => void
  53. let reject!: (x: any) => void
  54. const promise = new Promise((a, b) => {
  55. resolve = a
  56. reject = b
  57. })
  58. return { promise, resolve, reject }
  59. }
  60. function parentHarness(
  61. page: string,
  62. fetchUsers = async () => users,
  63. defaults = async () => ({ data: { status: '0', approverIds: [] } })
  64. ) {
  65. const writes: any[] = []
  66. let failSubmit = false
  67. let unmount = () => {}
  68. const bindings: any = {
  69. props: { embedded: false, recordId: '' },
  70. ref: vue.ref,
  71. computed: vue.computed,
  72. reactive: vue.reactive,
  73. watch: vue.watch,
  74. submitOpen: vue.ref(false),
  75. submitForm: vue.ref({ approverIds: [], remark: '' }),
  76. approverPickerOpen: vue.ref(false),
  77. userOptions: vue.ref([]),
  78. acting: vue.ref(false),
  79. route: vue.reactive({
  80. fullPath: '/original',
  81. name: page.startsWith('quote') ? 'MpCrmQuoteDetail' : 'MpCrmContractDetail',
  82. get path() {
  83. return `/m/crm/${page.startsWith('quote') ? 'quote' : 'contract'}/${this.params.id}`
  84. },
  85. params: { id: page.startsWith('quote') ? 'quote' : 'contract' },
  86. query: { from: '' }
  87. }),
  88. shellReady: true,
  89. disposed: false,
  90. loading: vue.ref(false),
  91. moreOpen: vue.ref(false),
  92. approveOpen: vue.ref(false),
  93. executeOpen: vue.ref(false),
  94. completeOpen: vue.ref(false),
  95. voidReasonPickerOpen: vue.ref(false),
  96. voidOpen: vue.ref(false),
  97. voidReasonOpen: vue.ref(false),
  98. products: vue.ref([]),
  99. approvalList: vue.ref([]),
  100. approvalError: vue.ref(''),
  101. approvalLoading: vue.ref(false),
  102. approvalRequestSeq: 0,
  103. userStore: vue.reactive({ accessToken: 'token', info: { id: 'user' } }),
  104. categoryPaths: vue.ref(new Map()),
  105. errorText: vue.ref(''),
  106. uploadOperation: null,
  107. linkedPlansRequestSeq: 0,
  108. linkedPlans: vue.ref([]),
  109. linkedPlansTotal: vue.ref(0),
  110. linkedPlansLoading: vue.ref(false),
  111. linkedPlansError: vue.ref(false),
  112. activeTab: vue.ref('contract'),
  113. partiesExpanded: vue.ref(false),
  114. detailRequestSeq: 0,
  115. categoryRequestSeq: 0,
  116. fabActions: vue.ref([{ key: 'submit' }]),
  117. contract: vue.ref({ id: 'contract' }),
  118. quote: vue.ref({ id: 'quote' }),
  119. fetchUserOptions: fetchUsers,
  120. getContractApproverSetting: defaults,
  121. getQuoteApproverSetting: defaults,
  122. ElMessage: { warning() {}, error() {}, success() {} },
  123. showFailToast() {},
  124. showSuccessToast() {},
  125. loadDetail: async () => {},
  126. reload() {},
  127. clearSelection() {},
  128. selectMode: vue.ref(true),
  129. batchTargetIds: vue.ref([]),
  130. batchSkipped: vue.ref(0),
  131. selectedRows: () => [
  132. { id: 'draft', status: 'draft' },
  133. { id: 'approved', status: 'approved' }
  134. ],
  135. submitContract: async (...args: any[]) => {
  136. writes.push(args)
  137. if (failSubmit) throw Error('offline')
  138. },
  139. submitQuote: async (...args: any[]) => {
  140. writes.push(args)
  141. if (failSubmit) throw Error('offline')
  142. },
  143. batchSubmitContract: async (...args: any[]) => {
  144. writes.push(args)
  145. if (failSubmit) throw Error('offline')
  146. return 1
  147. },
  148. onBeforeUnmount: (fn: any) => {
  149. unmount = fn
  150. }
  151. }
  152. const scope = vue.effectScope()
  153. const batch = page.includes('List')
  154. const names = [
  155. ...(!batch
  156. ? [
  157. 'operationEpoch',
  158. 'currentRecordId',
  159. 'captureOperation',
  160. 'isCurrentOperation',
  161. 'closeRecordActions',
  162. 'resetRecord'
  163. ]
  164. : []),
  165. 'approversLoading',
  166. 'approversError',
  167. 'submitGeneration',
  168. 'approverRows',
  169. 'selectedApprovers',
  170. 'approverSearchText',
  171. 'approverDescription',
  172. 'confirmApprovers',
  173. 'closeSubmitSession',
  174. 'loadUserOptions',
  175. 'resolvePrefillApproverIds',
  176. 'loadSubmitApprovers',
  177. 'retrySubmitApprovers',
  178. 'formatApproverOptionLabel',
  179. batch ? 'openBatchSubmit' : 'openSubmitSheet',
  180. batch ? 'confirmBatchSubmit' : 'confirmSubmit'
  181. ]
  182. const api = scope.run(() => evaluate(page, names, bindings, true))
  183. return {
  184. ...bindings,
  185. ...api,
  186. writes,
  187. open: api[batch ? 'openBatchSubmit' : 'openSubmitSheet'],
  188. submit: api[batch ? 'confirmBatchSubmit' : 'confirmSubmit'],
  189. fail: () => {
  190. failSubmit = true
  191. },
  192. dispose: () => {
  193. unmount()
  194. scope.stop()
  195. }
  196. }
  197. }
  198. function pickerHarness(parent: any) {
  199. const props = vue.reactive({
  200. show: true,
  201. title: '审批人',
  202. rows: parent.approverRows.value,
  203. value: parent.selectedApprovers.value,
  204. label: parent.formatApproverOptionLabel,
  205. description: parent.approverSearchText,
  206. searchText: parent.approverSearchText,
  207. multiple: true
  208. })
  209. const script = source('contract/ContractPickerSheet').scriptSetup!.content.replace(
  210. /^\s*import[^\n]+\n/gm,
  211. ''
  212. )
  213. const code = ts.transpile(script, { target: ts.ScriptTarget.ESNext })
  214. const scope = vue.effectScope()
  215. const bindings: any = {
  216. ref: vue.ref,
  217. computed: vue.computed,
  218. reactive: vue.reactive,
  219. watch: vue.watch,
  220. createContractPicker,
  221. defineProps: () => props,
  222. withDefaults: (p: any) => p,
  223. defineEmits: () => (event: string, value: any) => {
  224. if (event === 'confirm') parent.confirmApprovers(value)
  225. else if (event === 'update:show') props.show = value
  226. },
  227. onBeforeUnmount() {}
  228. }
  229. const api = scope.run(() =>
  230. new Function(
  231. ...Object.keys(bindings),
  232. code + ';return { pick, confirm, keyword, visibleRows, picker }'
  233. )(...Object.values(bindings))
  234. )
  235. return { ...api, props, dispose: () => scope.stop() }
  236. }
  237. for (const page of pages) {
  238. test(`${page}: actual picker confirm/cancel preserves order, searches name/account/no, and reselect appends`, async () => {
  239. const h = parentHarness(page)
  240. await h.open()
  241. const p = pickerHarness(h)
  242. const row = (id: string) => h.approverRows.value.find((u: any) => u.id === id)
  243. p.pick(row('B'))
  244. p.pick(row('A'))
  245. p.pick(row('C'))
  246. assert.deepEqual(h.submitForm.value.approverIds, [])
  247. p.props.show = false
  248. assert.deepEqual(h.submitForm.value.approverIds, [])
  249. p.props.show = true
  250. for (const term of [' 张三 ', ' ALICE ', ' no001 ']) {
  251. p.keyword.value = term
  252. assert.deepEqual(
  253. p.visibleRows.value.map((u: any) => u.id),
  254. ['A']
  255. )
  256. }
  257. p.pick(row('B'))
  258. p.pick(row('A'))
  259. p.keyword.value = 'NO003'
  260. p.pick(row('C'))
  261. p.keyword.value = 'missing'
  262. assert.equal(p.visibleRows.value.length, 0)
  263. p.confirm()
  264. assert.deepEqual(h.submitForm.value.approverIds, ['B', 'A', 'C'])
  265. p.props.value = h.selectedApprovers.value
  266. p.props.show = true
  267. assert.deepEqual(
  268. p.picker.selected.map((u: any) => u.id),
  269. ['B', 'A', 'C']
  270. )
  271. p.pick(row('A'))
  272. p.pick(row('A'))
  273. p.confirm()
  274. assert.deepEqual(h.submitForm.value.approverIds, ['B', 'C', 'A'])
  275. h.confirmApprovers([{ id: 'B' }, { id: '' }, { id: 'B' }, { id: 'C' }])
  276. assert.deepEqual(h.submitForm.value.approverIds, ['B', 'C'])
  277. h.approverPickerOpen.value = true
  278. h.submitOpen.value = false
  279. assert.equal(h.approverPickerOpen.value, false)
  280. p.dispose()
  281. h.dispose()
  282. })
  283. test(`${page}: candidate failure is visible and retry recovers; optional defaults fail safely`, async () => {
  284. let fail = true
  285. const h = parentHarness(
  286. page,
  287. async () => {
  288. if (fail) throw Error('offline')
  289. return users
  290. },
  291. async () => {
  292. throw Error('optional unavailable')
  293. }
  294. )
  295. await h.open()
  296. assert.equal(h.submitOpen.value, true)
  297. assert.match(h.approversError.value, /offline/)
  298. await h.submit()
  299. assert.equal(h.writes.length, 0)
  300. fail = false
  301. await h.retrySubmitApprovers()
  302. assert.equal(h.approversError.value, '')
  303. assert.equal(h.approverRows.value.length, 3)
  304. h.confirmApprovers([{ id: 'B' }, { id: 'A' }])
  305. h.fail()
  306. h.approverPickerOpen.value = true
  307. await h.submit()
  308. assert.equal(h.approverPickerOpen.value, false)
  309. assert.equal(h.submitOpen.value, true)
  310. assert.deepEqual(h.submitForm.value.approverIds, ['B', 'A'])
  311. const args = h.writes[0]
  312. if (page.startsWith('quote')) assert.deepEqual(args[0].approverIds, ['B', 'A'])
  313. else assert.deepEqual(args[1], ['B', 'A'])
  314. if (page.includes('List')) {
  315. assert.deepEqual(args[0], ['draft'])
  316. assert.equal(h.batchSkipped.value, 1)
  317. }
  318. h.dispose()
  319. })
  320. test(`${page}: old candidates/defaults never overwrite reentered or closed submit sessions`, async () => {
  321. const first = deferred()
  322. let count = 0
  323. const h = parentHarness(page, () =>
  324. ++count === 1 ? (first.promise as any) : Promise.resolve(users)
  325. )
  326. const old = h.open()
  327. await h.open()
  328. h.confirmApprovers([{ id: 'C' }])
  329. first.resolve([{ value: 'stale' }])
  330. await old
  331. assert.deepEqual(h.submitForm.value.approverIds, ['C'])
  332. assert.equal(h.approverRows.value.length, 3)
  333. h.dispose()
  334. const setting = deferred()
  335. let defaultsCount = 0
  336. const second = parentHarness(
  337. page,
  338. async () => users,
  339. () =>
  340. ++defaultsCount === 1
  341. ? (setting.promise as any)
  342. : Promise.resolve({ data: { status: '0', approverIds: ['B', 'invalid', 'A', 'B'] } })
  343. )
  344. const oldDefaults = second.open()
  345. await Promise.resolve()
  346. await Promise.resolve()
  347. second.submitOpen.value = false
  348. await second.open()
  349. assert.deepEqual(second.submitForm.value.approverIds, ['B', 'A'])
  350. setting.resolve({ data: { status: '0', approverIds: ['C'] } })
  351. await oldDefaults
  352. assert.deepEqual(second.submitForm.value.approverIds, ['B', 'A'])
  353. second.approverPickerOpen.value = true
  354. if (page.includes('List')) second.route.fullPath = '/another'
  355. else second.route.query.from = 'approve'
  356. await vue.nextTick()
  357. assert.equal(second.submitOpen.value, false)
  358. assert.equal(second.approverPickerOpen.value, false)
  359. second.dispose()
  360. })
  361. test(`${page}: descriptions only supplement labels with distinct account numbers`, async () => {
  362. const h = parentHarness(page)
  363. assert.equal(
  364. h.approverDescription({ nickName: '方金强', userName: '方金强', userNo: '方金强' }),
  365. ''
  366. )
  367. assert.equal(
  368. h.approverDescription({ nickName: '管理员', userName: 'admin', userNo: 'admin' }),
  369. ''
  370. )
  371. assert.equal(
  372. h.approverDescription({ nickName: '张三', userName: 'alice', userNo: 'NO001' }),
  373. '编号:NO001'
  374. )
  375. assert.match(
  376. h.approverSearchText({ nickName: '张三', userName: 'alice', userNo: 'NO001' }),
  377. /张三.*alice.*NO001/
  378. )
  379. h.dispose()
  380. })
  381. test(`${page}: closed or unmounted candidates and stale failures cannot clear a newer loading session`, async () => {
  382. const requests = [deferred(), deferred()]
  383. let count = 0
  384. const h = parentHarness(page, () => requests[count++].promise as any)
  385. const old = h.open()
  386. h.submitOpen.value = false
  387. const fresh = h.open()
  388. requests[0].reject(Error('old failure'))
  389. await old
  390. assert.equal(h.approversError.value, '')
  391. assert.equal(h.approversLoading.value, true)
  392. h.approverPickerOpen.value = true
  393. h.dispose()
  394. requests[1].resolve(users)
  395. await fresh
  396. assert.equal(h.approverPickerOpen.value, false)
  397. assert.deepEqual(h.userOptions.value, [])
  398. })
  399. test(`${page}: rendered picker wires local selection and retry to production handlers`, () => {
  400. const template = source(page).template!.content
  401. assert.match(
  402. template,
  403. /<ContractPickerSheet[\s\S]*?:value="selectedApprovers"[\s\S]*?@confirm="confirmApprovers"/
  404. )
  405. assert.match(template, /@click="retrySubmitApprovers"/)
  406. assert.doesNotMatch(template, /v-model="submitForm.approverIds"/)
  407. })
  408. }
  409. test('quote expiry matches desktop day boundaries including date-only, datetime and invalid values', () => {
  410. class FixedDate extends Date {
  411. static now() {
  412. return new Date('2026-10-21T04:00:00Z').getTime()
  413. }
  414. }
  415. const quote = vue.ref<any>({})
  416. const { expireText } = evaluate('quote/detail', ['expireText'], {
  417. computed: vue.computed,
  418. quote,
  419. Date: FixedDate
  420. })
  421. for (const [value, expected] of [
  422. ['2026-10-23', '2026-10-23(剩余2天)'],
  423. ['2026-10-21', '2026-10-21(今天到期)'],
  424. ['2026-10-19', '2026-10-19(已过期)'],
  425. ['2026-10-21T04:00:00Z', '2026-10-21T04:00:00Z(今天到期)'],
  426. ['', '—'],
  427. ['bad', 'bad']
  428. ]) {
  429. quote.value = { expireDate: value }
  430. assert.equal(expireText.value, expected)
  431. }
  432. })