mobile-quote-selectors.test.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534
  1. import assert from 'node:assert/strict'
  2. import { readFileSync } from 'node:fs'
  3. import test from 'node:test'
  4. import { compile, computed, createSSRApp, effectScope, proxyRefs, ref, watch } from 'vue'
  5. import { renderToString } from 'vue/server-renderer'
  6. import { parse } from '@vue/compiler-sfc'
  7. import ts from 'typescript'
  8. const file = new URL('../src/views/crm/quote/components/QuoteFormPanel.vue', import.meta.url)
  9. const { descriptor } = parse(readFileSync(file, 'utf8'))
  10. const ast = ts.createSourceFile(
  11. 'QuoteFormPanel.ts',
  12. descriptor.scriptSetup!.content,
  13. ts.ScriptTarget.Latest,
  14. true
  15. )
  16. function deferred() {
  17. let resolve!: (value: any) => void
  18. let reject!: (error: Error) => void
  19. const promise = new Promise<any>((yes, no) => {
  20. resolve = yes
  21. reject = no
  22. })
  23. return { promise, resolve, reject }
  24. }
  25. // Run the component's actual mobile declarations with Vue reactivity and transport stubs.
  26. // No component mount, browser, HTTP request, or database is involved.
  27. function harness(page = true) {
  28. const names: string[] = []
  29. const selected = ast.statements.filter((node) => {
  30. if (ts.isVariableStatement(node)) {
  31. const name = node.declarationList.declarations[0].name.getText(ast)
  32. if (
  33. /^(mobile|filteredMobile|scheduleMobile)/.test(name) ||
  34. ['approverPickerOpen', 'extractCustomerList', 'handleAddProduct'].includes(name)
  35. ) {
  36. names.push(name)
  37. return true
  38. }
  39. }
  40. if (ts.isFunctionDeclaration(node)) {
  41. const name = node.name?.text || ''
  42. if (/Mobile|^createMobile|^toggleApprover$/.test(name)) {
  43. names.push(name)
  44. return true
  45. }
  46. }
  47. return (
  48. ts.isExpressionStatement(node) &&
  49. /^(watch\(|onBeforeUnmount\(|useIntersectionObserver\()/.test(node.getText(ast)) &&
  50. /[mM]obile/.test(node.getText(ast))
  51. )
  52. })
  53. const timers = new Map<number, { fn: () => void; at: number }>()
  54. let now = 0
  55. let timerId = 0
  56. const setTimer = (fn: () => void, delay = 0) => {
  57. timers.set(++timerId, { fn, at: now + delay })
  58. return timerId
  59. }
  60. const clearTimer = (id: number) => timers.delete(id)
  61. const advance = async (ms: number) => {
  62. now += ms
  63. for (const [id, timer] of [...timers]) {
  64. if (timer.at > now) continue
  65. timers.delete(id)
  66. timer.fn()
  67. }
  68. for (let i = 0; i < 8; i++) await Promise.resolve()
  69. }
  70. const calls: Array<{ kind: string; query: any; request: ReturnType<typeof deferred> }> = []
  71. const transport = (kind: string) => (query: any) => {
  72. const request = deferred()
  73. calls.push({ kind, query, request })
  74. return request.promise
  75. }
  76. const cleanups: Array<() => void> = []
  77. const observers: Array<() => void> = []
  78. const confirmed: any[][] = []
  79. const bindings = {
  80. ref,
  81. computed,
  82. watch,
  83. setTimeout: setTimer,
  84. clearTimeout: clearTimer,
  85. onBeforeUnmount: (fn: () => void) => cleanups.push(fn),
  86. useIntersectionObserver: (_target: any, fn: any) =>
  87. observers.push(() => fn([{ isIntersecting: true }])),
  88. useDebounceFn: (fn: () => void, delay: number) => {
  89. let id: number
  90. return () => {
  91. clearTimer(id)
  92. id = setTimer(fn, delay)
  93. }
  94. },
  95. isPageVariant: ref(page),
  96. route: { fullPath: '/m/crm/quote/new' },
  97. contactOptionsRequest: 0,
  98. contactOptionsLoading: ref(false),
  99. contactOptionsError: ref(''),
  100. form: ref({ supplierId: 's1', customerId: 'c1', products: [] }),
  101. productSelectorVisible: ref(false),
  102. customerSelectorVisible: ref(false),
  103. opportunitySelectorVisible: ref(false),
  104. contactSelectorVisible: ref(false),
  105. contactOptions: ref([
  106. { id: 'c1', contactName: '张三', mobile: '13812345678' },
  107. { id: 'c2', contactName: '李四', mobile: '13987654321' }
  108. ]),
  109. submitForm: ref({ approverIds: ['u2', 'u1'] }),
  110. userList: ref([
  111. { userId: 'u1', nickName: '张三' },
  112. { userId: 'u2', nickName: '李四' }
  113. ]),
  114. formatApproverOptionLabel: (user: any) => user.nickName,
  115. fetchProductList: transport('product'),
  116. fetchCustomerList: transport('customer'),
  117. listOpportunity: transport('opportunity'),
  118. handleProductConfirm: (items: any[]) => confirmed.push(items),
  119. showFailToast: () => {},
  120. $modal: { msgWarning: () => {} },
  121. loadContactOptions: async () => {},
  122. handleContactConfirm: () => {},
  123. handleOpportunityConfirm: () => {},
  124. hasCustomerShortName: () => true,
  125. handleCustomerConfirm: async () => {}
  126. }
  127. const code = ts.transpile(selected.map((node) => node.getText(ast)).join('\n'), {
  128. target: ts.ScriptTarget.ESNext
  129. })
  130. const scope = effectScope()
  131. const api = scope.run(() =>
  132. new Function(...Object.keys(bindings), `${code}\nreturn { ${names.join(', ')} }`)(
  133. ...Object.values(bindings)
  134. )
  135. )
  136. return {
  137. ...bindings,
  138. ...api,
  139. calls,
  140. confirmed,
  141. observers,
  142. advance,
  143. async flush() {
  144. for (let i = 0; i < 8; i++) await Promise.resolve()
  145. },
  146. async debounce() {
  147. await advance(250)
  148. },
  149. dispose() {
  150. cleanups.forEach((fn) => fn())
  151. scope.stop()
  152. }
  153. }
  154. }
  155. const row = (id: string) => ({ id, productName: id, customerName: id, opportunityName: id })
  156. const response = (ids: string[], total: number | string = ids.length) => ({
  157. data: { list: ids.map(row), total }
  158. })
  159. async function statusMarkup(kind: string, h: any) {
  160. let body: any
  161. const visit = (node: any) => {
  162. if (
  163. node.tag === 'div' &&
  164. node.children?.some(
  165. (child: any) =>
  166. child.tag === 'van-search' && child.loc.source.includes(`mobile${kind}Keyword`)
  167. )
  168. )
  169. body = node
  170. node.children?.forEach(visit)
  171. }
  172. visit(descriptor.template!.ast)
  173. assert.ok(body)
  174. const fragment = body.children
  175. .filter(
  176. (node: any) =>
  177. node.type === 1 &&
  178. !['van-search', 'van-cell', 'van-checkbox-group'].includes(node.tag) &&
  179. !node.props?.some((prop: any) => prop.type === 7 && prop.name === 'for')
  180. )
  181. .map((node: any) => node.loc.source)
  182. .join('\n')
  183. const compiled = compile(fragment, { isCustomElement: () => true })
  184. const bindings = proxyRefs(h)
  185. return renderToString(createSSRApp({ render: () => compiled.call(bindings, bindings, []) }))
  186. }
  187. test('product page two failure retries page two without skipping it', async () => {
  188. const h = harness()
  189. const open = h.handleAddProduct()
  190. h.calls[0].request.resolve(response(['p1'], 50))
  191. await open
  192. const more = h.loadMoreMobileProducts()
  193. h.calls[1].request.reject(new Error('offline'))
  194. await more
  195. const retry = h.loadMoreMobileProducts()
  196. assert.equal(h.calls[2].query.page, 2)
  197. h.calls[2].request.resolve(response(['p2'], 50))
  198. await retry
  199. assert.deepEqual(
  200. h.mobileProductList.value.map((item: any) => item.id),
  201. ['p1', 'p2']
  202. )
  203. h.dispose()
  204. })
  205. test('typing invalidates the in-flight product request before the debounce fires', async () => {
  206. const h = harness()
  207. const open = h.handleAddProduct()
  208. h.mobileProductKeyword.value = '乙'
  209. h.onMobileProductKeywordInput?.()
  210. h.calls[0].request.resolve(response(['stale']))
  211. await open
  212. assert.deepEqual(h.mobileProductList.value, [])
  213. await h.debounce()
  214. assert.equal(h.calls.at(-1).query.keyword, '乙')
  215. h.calls.at(-1).request.resolve(response(['new']))
  216. await h.flush()
  217. assert.equal(h.mobileProductList.value[0].id, 'new')
  218. h.dispose()
  219. })
  220. test('old product finally cannot clear loading after closing and reopening', async () => {
  221. const h = harness()
  222. const first = h.handleAddProduct()
  223. h.productSelectorVisible.value = false
  224. const second = h.handleAddProduct()
  225. h.calls[0].request.resolve(response(['stale']))
  226. await first
  227. assert.equal(h.mobileProductLoading.value, true)
  228. assert.deepEqual(h.mobileProductList.value, [])
  229. h.calls[1].request.resolve(response(['fresh']))
  230. await second
  231. h.productSelectorVisible.value = false
  232. const third = h.handleAddProduct()
  233. h.productSelectorVisible.value = false
  234. h.calls[2].request.resolve(response(['closed']))
  235. await third
  236. assert.deepEqual(h.mobileProductList.value, [])
  237. h.dispose()
  238. })
  239. test('cross-search product selection confirms cached objects in selection order', async () => {
  240. const h = harness()
  241. const open = h.handleAddProduct()
  242. h.calls[0].request.resolve(response(['A']))
  243. await open
  244. h.toggleMobileProduct('A')
  245. h.mobileProductKeyword.value = 'B'
  246. const search = h.loadMobileProducts()
  247. h.calls.at(-1).request.resolve(response(['B']))
  248. await search
  249. h.toggleMobileProduct('B')
  250. h.confirmMobileProductPick()
  251. assert.deepEqual(
  252. h.confirmed[0].map((item: any) => item.id),
  253. ['A', 'B']
  254. )
  255. h.dispose()
  256. })
  257. test('supplier or customer changes prevent confirming products from the previous context', async () => {
  258. for (const key of ['supplierId', 'customerId']) {
  259. const h = harness()
  260. const open = h.handleAddProduct()
  261. h.calls[0].request.resolve(response(['A']))
  262. await open
  263. h.toggleMobileProduct('A')
  264. h.form.value[key] = 'changed'
  265. h.confirmMobileProductPick()
  266. assert.equal(h.confirmed.length, 0)
  267. assert.equal(h.productSelectorVisible.value, false)
  268. h.dispose()
  269. }
  270. })
  271. test('customer input and clear search automatically; old responses cannot overwrite newer results', async () => {
  272. const h = harness()
  273. const open = h.openMobileCustomer()
  274. h.calls[0].request.resolve(response(['initial']))
  275. await open
  276. h.mobileCustomerKeyword.value = '池州'
  277. await h.debounce()
  278. assert.equal(h.calls.length, 2)
  279. h.mobileCustomerKeyword.value = '国臣'
  280. await h.debounce()
  281. h.calls[2].request.resolve(response(['current']))
  282. await h.flush()
  283. h.calls[1].request.resolve(response(['old']))
  284. await h.flush()
  285. assert.deepEqual(
  286. h.mobileCustomerList.value.map((item: any) => item.id),
  287. ['current']
  288. )
  289. h.mobileCustomerKeyword.value = ''
  290. await h.debounce()
  291. assert.equal(h.calls[3].query.keyword, undefined)
  292. h.calls[3].request.resolve(response(['cleared']))
  293. await h.flush()
  294. h.dispose()
  295. })
  296. test('customer and opportunity matches beyond thirty remain reachable', async () => {
  297. for (const kind of ['Customer', 'Opportunity']) {
  298. const h = harness()
  299. const open = h[`openMobile${kind}`]()
  300. const rows = Array.from({ length: 30 }, (_, i) => row(String(i)))
  301. h.calls[0].request.resolve(
  302. kind === 'Customer' ? { data: { list: rows, total: '31' } } : { rows, total: '31' }
  303. )
  304. await open
  305. assert.equal(
  306. typeof h[`loadMoreMobile${kind === 'Customer' ? 'Customers' : 'Opportunities'}`],
  307. 'function'
  308. )
  309. const more = h[`loadMoreMobile${kind === 'Customer' ? 'Customers' : 'Opportunities'}`]()
  310. assert.equal(h.calls[1].query.page ?? h.calls[1].query.pageNum, 2)
  311. h.calls[1].request.resolve(
  312. kind === 'Customer' ? response(['30'], 31) : { rows: [row('30')], total: 31 }
  313. )
  314. await more
  315. assert.equal(h[`mobile${kind}List`].value.length, 31)
  316. h.dispose()
  317. }
  318. })
  319. test('opportunity customer change discards the old response and queries the new customer', async () => {
  320. const h = harness()
  321. const open = h.openMobileOpportunity()
  322. h.form.value.customerId = 'c2'
  323. h.calls[0].request.resolve({ rows: [row('old')], total: 1 })
  324. await open
  325. assert.deepEqual(h.mobileOpportunityList.value, [])
  326. await h.debounce()
  327. assert.equal(h.calls[1].query.customerId, 'c2')
  328. h.calls[1].request.resolve({ rows: [row('new')], total: 1 })
  329. await h.flush()
  330. assert.equal(h.mobileOpportunityList.value[0].id, 'new')
  331. h.dispose()
  332. })
  333. test('unmount invalidates unresolved requests and scheduled searches', async () => {
  334. const h = harness()
  335. const open = h.handleAddProduct()
  336. h.mobileProductKeyword.value = 'later'
  337. h.onMobileProductKeywordInput?.()
  338. h.dispose()
  339. h.calls[0].request.resolve(response(['unmounted']))
  340. await open
  341. await h.debounce()
  342. assert.deepEqual(h.mobileProductList.value, [])
  343. assert.equal(h.calls.length, 1)
  344. })
  345. test('mobile contact and approver filters search locally without changing selected order', () => {
  346. const h = harness()
  347. assert.ok(h.mobileContactKeyword)
  348. h.mobileContactKeyword.value = '8765'
  349. assert.deepEqual(
  350. h.filteredMobileContacts.value.map((item: any) => item.id),
  351. ['c2']
  352. )
  353. h.mobileContactKeyword.value = '张'
  354. assert.deepEqual(
  355. h.filteredMobileContacts.value.map((item: any) => item.id),
  356. ['c1']
  357. )
  358. h.mobileApproverKeyword.value = '张'
  359. assert.deepEqual(
  360. h.filteredMobileApprovers.value.map((item: any) => item.userId),
  361. ['u1']
  362. )
  363. assert.deepEqual(h.submitForm.value.approverIds, ['u2', 'u1'])
  364. h.dispose()
  365. })
  366. test('first-page errors expose retry and never masquerade as an empty result', async () => {
  367. for (const kind of ['Product', 'Customer', 'Opportunity']) {
  368. const h = harness()
  369. const open = kind === 'Product' ? h.handleAddProduct() : h[`openMobile${kind}`]()
  370. assert.doesNotMatch(await statusMarkup(kind, h), /暂无/)
  371. h.calls[0].request.reject(new Error('offline'))
  372. await open
  373. const markup = await statusMarkup(kind, h)
  374. assert.match(markup, /加载失败/)
  375. assert.match(markup, /重试/)
  376. assert.doesNotMatch(markup, /暂无/)
  377. h.dispose()
  378. }
  379. })
  380. test('load-more errors retain rows, expose a retry button, and stop automatic retries', async () => {
  381. const h = harness()
  382. const open = h.handleAddProduct()
  383. h.calls[0].request.resolve(response(['A'], 40))
  384. await open
  385. const more = h.loadMoreMobileProducts()
  386. h.calls[1].request.reject(new Error('offline'))
  387. await more
  388. assert.deepEqual(
  389. h.mobileProductList.value.map((item: any) => item.id),
  390. ['A']
  391. )
  392. const markup = await statusMarkup('Product', h)
  393. assert.match(markup, /加载失败/)
  394. assert.match(markup, /重试加载更多/)
  395. h.observers[0]()
  396. assert.equal(h.calls.length, 2)
  397. h.dispose()
  398. })
  399. test('the explicit search action cancels a pending debounce request', async () => {
  400. const h = harness()
  401. const open = h.openMobileCustomer()
  402. h.calls[0].request.resolve(response(['A']))
  403. await open
  404. h.mobileCustomerKeyword.value = 'B'
  405. const search = h.loadMobileCustomers()
  406. h.calls[1].request.resolve(response(['B']))
  407. await search
  408. await h.debounce()
  409. assert.equal(h.calls.length, 2)
  410. h.dispose()
  411. })
  412. test('closing clears pending product choices without writing the form; reopening starts fresh', async () => {
  413. const h = harness()
  414. const open = h.handleAddProduct()
  415. h.calls[0].request.resolve(response(['A']))
  416. await open
  417. h.onMobileProductSelectionChange(['A'])
  418. h.productSelectorVisible.value = false
  419. assert.deepEqual(h.form.value.products, [])
  420. assert.equal(h.confirmed.length, 0)
  421. const reopen = h.handleAddProduct()
  422. h.calls[1].request.resolve(response(['A']))
  423. await reopen
  424. assert.deepEqual(h.mobileProductSelectedIds.value, [])
  425. h.dispose()
  426. })
  427. test('desktop supplier changes and selector visibility never trigger mobile transport or cleanup', async () => {
  428. const h = harness(false)
  429. await h.handleAddProduct()
  430. assert.equal(h.productSelectorVisible.value, true)
  431. h.form.value.supplierId = 's2'
  432. h.form.value.customerId = 'c2'
  433. h.customerSelectorVisible.value = true
  434. h.opportunitySelectorVisible.value = true
  435. h.mobileCustomerKeyword.value = 'A'
  436. await h.debounce()
  437. assert.equal(h.productSelectorVisible.value, true)
  438. assert.equal(h.calls.length, 0)
  439. h.dispose()
  440. })
  441. test('all remote pickers debounce typing for 250 ms and keep their original filters', async () => {
  442. for (const kind of ['Product', 'Customer', 'Opportunity']) {
  443. const h = harness()
  444. const open = kind === 'Product' ? h.handleAddProduct() : h[`openMobile${kind}`]()
  445. h.calls[0].request.resolve(response(['A']))
  446. await open
  447. h[`mobile${kind}Keyword`].value = ' 查询 '
  448. await h.advance(249)
  449. assert.equal(h.calls.length, 1)
  450. await h.advance(1)
  451. assert.equal(h.calls.length, 2)
  452. assert.equal(h.calls[1].query.keyword, '查询')
  453. if (kind === 'Product') {
  454. assert.deepEqual(h.calls[1].query, {
  455. page: 1,
  456. pageSize: 20,
  457. status: 'on',
  458. keyword: '查询',
  459. matchProductNo: false,
  460. matchModel: true,
  461. supplierId: 's1'
  462. })
  463. } else if (kind === 'Opportunity') {
  464. assert.equal(h.calls[1].query.customerId, 'c1')
  465. }
  466. h.calls[1].request.resolve(response(['B']))
  467. await h.flush()
  468. h.dispose()
  469. }
  470. })
  471. test('customer and opportunity closing/reopening discard old success and error responses', async () => {
  472. for (const kind of ['Customer', 'Opportunity']) {
  473. const h = harness()
  474. const visible = h[`${kind.toLowerCase()}SelectorVisible`]
  475. const first = h[`openMobile${kind}`]()
  476. visible.value = false
  477. const second = h[`openMobile${kind}`]()
  478. h.calls[0].request.reject(new Error('stale failure'))
  479. await first
  480. assert.equal(h[`mobile${kind}Loading`].value, true)
  481. assert.equal(h[`mobile${kind}Error`].value, '')
  482. h.calls[1].request.resolve(response(['fresh']))
  483. await second
  484. visible.value = false
  485. const third = h[`openMobile${kind}`]()
  486. visible.value = false
  487. h.calls[2].request.resolve(response(['closed']))
  488. await third
  489. assert.deepEqual(h[`mobile${kind}List`].value, [])
  490. h.dispose()
  491. }
  492. })
  493. test('product checkbox changes preserve cached selection across pages and deselection order', async () => {
  494. const h = harness()
  495. const open = h.handleAddProduct()
  496. h.calls[0].request.resolve(response(['A'], 40))
  497. await open
  498. h.onMobileProductSelectionChange(['A'])
  499. const more = h.loadMoreMobileProducts()
  500. h.calls[1].request.resolve(response(['B'], 40))
  501. await more
  502. h.onMobileProductSelectionChange(['A', 'B'])
  503. h.onMobileProductSelectionChange(['B'])
  504. h.onMobileProductSelectionChange(['B', 'A'])
  505. h.confirmMobileProductPick()
  506. assert.deepEqual(
  507. h.confirmed[0].map((item: any) => item.id),
  508. ['B', 'A']
  509. )
  510. h.dispose()
  511. })