mobile-crm-approvers.test.ts 13 KB

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