mobile-contract-list-return.test.ts 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  1. import assert from 'node:assert/strict'
  2. import test from 'node:test'
  3. import { readFileSync } from 'node:fs'
  4. import { ref, nextTick, watch } from 'vue'
  5. import ts from 'typescript'
  6. import { parse } from '@vue/compiler-sfc'
  7. import * as routeUtils from '../src/utils/mp-webview'
  8. import * as listState from '../src/views/m/crm/contract/listState'
  9. function harness(mode: 'manage' | 'approve') {
  10. const base = mode === 'approve' ? '/m/crm/contract/approval' : '/m/crm/contract'
  11. let route: any = { path: base, query: {} }
  12. let leave: any, unmount: any
  13. const hooks = new Set<any>()
  14. const scrolls: number[] = []
  15. const bindings = {
  16. ref,
  17. nextTick,
  18. watch,
  19. useRoute: () => route,
  20. useRouter: () => ({
  21. afterEach(fn: any) {
  22. hooks.add(fn)
  23. return () => hooks.delete(fn)
  24. }
  25. }),
  26. useUserStore: () => ({ accessToken: 'token', getUserInfo: { id: 'u', tenantNo: 'T' } }),
  27. onBeforeRouteLeave: (fn: any) => {
  28. leave = fn
  29. },
  30. onBeforeUnmount: (fn: any) => {
  31. unmount = fn
  32. },
  33. window: { scrollY: 720, scrollTo: ({ top }: any) => scrolls.push(top) }
  34. }
  35. const source = readFileSync(
  36. new URL('../src/views/m/crm/useListReturnState.ts', import.meta.url),
  37. 'utf8'
  38. )
  39. .replace(/^import .*$/gm, '')
  40. .replace('export function', 'function')
  41. const useState = new Function(
  42. ...Object.keys(bindings),
  43. ts.transpile(source, { target: ts.ScriptTarget.ESNext }) + ';return useListReturnState'
  44. )(...Object.values(bindings))
  45. // Evaluate the real contract call-site so this catches missing predicate wiring, not only the helper.
  46. const ast = ts.createSourceFile(
  47. 'list.ts',
  48. parse(
  49. readFileSync(
  50. new URL('../src/views/m/crm/contract/MpContractList.vue', import.meta.url),
  51. 'utf8'
  52. )
  53. ).descriptor.scriptSetup!.content,
  54. ts.ScriptTarget.Latest,
  55. true
  56. )
  57. const statement = ast.statements.find(
  58. (n) =>
  59. ts.isVariableStatement(n) &&
  60. n.declarationList.declarations.some((d) => d.name.getText(ast) === 'listReturnState')
  61. )!
  62. function create() {
  63. let failPage = 0
  64. const keyword = ref(''),
  65. query = ref({}),
  66. view = ref(mode === 'approve' ? 'submitted' : 'all'),
  67. tabQueries = ref({})
  68. const pageNum = ref(3),
  69. expandedIds = ref(new Set(['Q1'])),
  70. hasMore = ref(true)
  71. const requests: any[] = []
  72. const local = {
  73. ...listState,
  74. ...routeUtils,
  75. props: { mode },
  76. keyword,
  77. query,
  78. view,
  79. tabQueries,
  80. pageNum,
  81. expandedIds,
  82. hasMore,
  83. useListReturnState: useState,
  84. requestSnapshot: () => JSON.stringify([keyword.value, query.value, view.value]),
  85. fetchPage: async (reset: boolean) => {
  86. const page = reset ? 1 : pageNum.value + 1
  87. requests.push({ page, keyword: keyword.value, query: query.value })
  88. if (page === failPage) return false
  89. pageNum.value = page
  90. expandedIds.value = new Set()
  91. return true
  92. }
  93. }
  94. const state = new Function(
  95. ...Object.keys(local),
  96. ts.transpile(statement.getText(ast), { target: ts.ScriptTarget.ESNext }) +
  97. ';return listReturnState'
  98. )(...Object.values(local))
  99. const template = parse(
  100. readFileSync(
  101. new URL('../src/views/m/crm/contract/MpContractList.vue', import.meta.url),
  102. 'utf8'
  103. )
  104. ).descriptor.template!.content
  105. const retryExpression = template.match(/<van-button[^>]*@click="([^"]+)"[^>]*>\s*重试/)[1]
  106. const retry = () =>
  107. new Function('listReturnState', 'contracts', 'fetchPage', 'return ' + retryExpression)(
  108. state,
  109. [],
  110. local.fetchPage
  111. )
  112. return {
  113. state,
  114. keyword,
  115. query,
  116. pageNum,
  117. expandedIds,
  118. requests,
  119. retry,
  120. failAt: (page: number) => {
  121. failPage = page
  122. }
  123. }
  124. }
  125. function navigate(to: any) {
  126. const from = route
  127. route = to
  128. for (const hook of hooks) hook(to, from)
  129. }
  130. function detail() {
  131. const to = { path: '/m/crm/contract/Q1', query: { from: mode } }
  132. leave(to)
  133. navigate(to)
  134. unmount()
  135. }
  136. return { base, create, navigate, detail, scrolls }
  137. }
  138. for (const mode of ['manage', 'approve'] as const) {
  139. test(`${mode}: contract/related/contract round trip restores search, filters, pages and scroll`, async () => {
  140. const h = harness(mode),
  141. first = h.create()
  142. first.keyword.value = '移动验收'
  143. first.query.value = { amountMin: '10' }
  144. h.detail()
  145. h.navigate({
  146. path: '/m/crm/customer/R1',
  147. query: { from: 'contract', returnContractId: 'Q1', contractFrom: mode }
  148. })
  149. h.navigate({
  150. path: '/m/crm/quote/Q2',
  151. query: { from: 'contract', returnContractId: 'Q1', contractFrom: mode }
  152. })
  153. h.navigate({
  154. path: '/m/crm/customer/R1',
  155. query: {
  156. from: 'quote',
  157. returnQuoteId: 'Q2',
  158. quoteFrom: 'contract',
  159. returnContractId: 'Q1',
  160. contractFrom: mode
  161. }
  162. })
  163. h.navigate({
  164. path: '/m/crm/quote/Q2',
  165. query: { from: 'contract', returnContractId: 'Q1', contractFrom: mode }
  166. })
  167. h.navigate({ path: '/m/crm/contract/Q1', query: { from: mode } })
  168. h.navigate({ path: h.base, query: {} })
  169. const back = h.create()
  170. await back.state.restore()
  171. assert.equal(back.keyword.value, '移动验收')
  172. assert.deepEqual(back.query.value, { amountMin: '10' })
  173. assert.deepEqual(
  174. back.requests.map((r: any) => r.page),
  175. [1, 2, 3]
  176. )
  177. assert.deepEqual([...back.expandedIds.value], ['Q1'])
  178. assert.deepEqual(h.scrolls, [720])
  179. })
  180. }
  181. test('unrelated customer paths and forged contract return IDs discard the contract snapshot', async () => {
  182. for (const query of [
  183. {},
  184. { from: 'customer', returnContractId: 'Q1' },
  185. { from: 'contract', returnContractId: '../Q1' }
  186. ]) {
  187. const h = harness('manage'),
  188. first = h.create()
  189. first.keyword.value = 'private'
  190. h.detail()
  191. h.navigate({ path: '/m/crm/customer/R1', query })
  192. h.navigate({ path: '/m/crm/contract/Q1', query: {} })
  193. h.navigate({ path: h.base, query: {} })
  194. const back = h.create()
  195. await back.state.restore()
  196. assert.equal(back.keyword.value, '')
  197. }
  198. })
  199. for (const mode of ['manage', 'approve'] as const)
  200. for (const failedPage of [1, 2]) {
  201. test(`${mode}: retry after restoration failure at page ${failedPage} preserves original pagination and scroll`, async () => {
  202. const h = harness(mode),
  203. first = h.create()
  204. first.keyword.value = '保存的搜索'
  205. h.detail()
  206. h.navigate({ path: h.base, query: {} })
  207. const back = h.create()
  208. back.failAt(failedPage)
  209. await back.state.restore()
  210. assert.deepEqual(h.scrolls, [])
  211. back.failAt(0)
  212. await back.retry()
  213. assert.equal(back.pageNum.value, 3)
  214. assert.equal(back.keyword.value, '保存的搜索')
  215. assert.deepEqual([...back.expandedIds.value], ['Q1'])
  216. assert.deepEqual(h.scrolls, [720])
  217. assert.deepEqual(
  218. back.requests.map((r: any) => r.page),
  219. failedPage === 1 ? [1, 1, 2, 3] : [1, 2, 1, 2, 3]
  220. )
  221. })
  222. }