mobile-quote-list-return.test.ts 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. import assert from 'node:assert/strict'
  2. import test from 'node:test'
  3. import { readFileSync } from 'node:fs'
  4. import { ref, nextTick } 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/quote/listState'
  9. function harness(mode: 'manage' | 'approve') {
  10. const base = mode === 'approve' ? '/m/crm/quote/approval' : '/m/crm/quote'
  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. useRoute: () => route,
  19. useRouter: () => ({
  20. afterEach(fn: any) {
  21. hooks.add(fn)
  22. return () => hooks.delete(fn)
  23. }
  24. }),
  25. useUserStore: () => ({ accessToken: 'token', getUserInfo: { id: 'u', tenantNo: 'T' } }),
  26. onBeforeRouteLeave: (fn: any) => {
  27. leave = fn
  28. },
  29. onBeforeUnmount: (fn: any) => {
  30. unmount = fn
  31. },
  32. window: { scrollY: 720, scrollTo: ({ top }: any) => scrolls.push(top) }
  33. }
  34. const source = readFileSync(
  35. new URL('../src/views/m/crm/useListReturnState.ts', import.meta.url),
  36. 'utf8'
  37. )
  38. .replace(/^import .*$/gm, '')
  39. .replace('export function', 'function')
  40. const useState = new Function(
  41. ...Object.keys(bindings),
  42. ts.transpile(source, { target: ts.ScriptTarget.ESNext }) + ';return useListReturnState'
  43. )(...Object.values(bindings))
  44. // Evaluate the real quote call-site so this catches missing predicate wiring, not only the helper.
  45. const ast = ts.createSourceFile(
  46. 'list.ts',
  47. parse(
  48. readFileSync(new URL('../src/views/m/crm/quote/MpQuoteList.vue', import.meta.url), 'utf8')
  49. ).descriptor.scriptSetup!.content,
  50. ts.ScriptTarget.Latest,
  51. true
  52. )
  53. const statement = ast.statements.find(
  54. (n) =>
  55. ts.isVariableStatement(n) &&
  56. n.declarationList.declarations.some((d) => d.name.getText(ast) === 'listReturnState')
  57. )!
  58. function create() {
  59. const keyword = ref(''),
  60. query = ref({}),
  61. view = ref(mode === 'approve' ? 'submitted' : 'all'),
  62. tabQueries = ref({})
  63. const pageNum = ref(3),
  64. expandedIds = ref(new Set(['Q1'])),
  65. hasMore = ref(true)
  66. const requests: any[] = []
  67. const local = {
  68. ...listState,
  69. ...routeUtils,
  70. props: { mode },
  71. keyword,
  72. query,
  73. view,
  74. tabQueries,
  75. pageNum,
  76. expandedIds,
  77. hasMore,
  78. useListReturnState: useState,
  79. requestSnapshot: () => JSON.stringify([keyword.value, query.value, view.value]),
  80. fetchPage: async (reset: boolean) => {
  81. pageNum.value = reset ? 1 : pageNum.value + 1
  82. requests.push({ page: pageNum.value, keyword: keyword.value, query: query.value })
  83. expandedIds.value = new Set()
  84. return true
  85. }
  86. }
  87. const state = new Function(
  88. ...Object.keys(local),
  89. ts.transpile(statement.getText(ast), { target: ts.ScriptTarget.ESNext }) +
  90. ';return listReturnState'
  91. )(...Object.values(local))
  92. return { state, keyword, query, pageNum, expandedIds, requests }
  93. }
  94. function navigate(to: any) {
  95. const from = route
  96. route = to
  97. for (const hook of hooks) hook(to, from)
  98. }
  99. function detail() {
  100. const to = { path: '/m/crm/quote/Q1', query: { from: mode } }
  101. leave(to)
  102. navigate(to)
  103. unmount()
  104. }
  105. return { base, create, navigate, detail, scrolls }
  106. }
  107. for (const mode of ['manage', 'approve'] as const) {
  108. test(`${mode}: quote/customer/quote round trip restores search, filters, pages and scroll`, async () => {
  109. const h = harness(mode),
  110. first = h.create()
  111. first.keyword.value = '移动验收'
  112. first.query.value = { finalAmountMin: '10' }
  113. h.detail()
  114. h.navigate({
  115. path: '/m/crm/customer/C1',
  116. query: { from: 'quote', returnQuoteId: 'Q1', quoteFrom: mode }
  117. })
  118. h.navigate({ path: '/m/crm/quote/Q1', query: { from: mode } })
  119. h.navigate({ path: h.base, query: {} })
  120. const back = h.create()
  121. await back.state.restore()
  122. assert.equal(back.keyword.value, '移动验收')
  123. assert.deepEqual(back.query.value, { finalAmountMin: '10' })
  124. assert.deepEqual(
  125. back.requests.map((r: any) => r.page),
  126. [1, 2, 3]
  127. )
  128. assert.deepEqual([...back.expandedIds.value], ['Q1'])
  129. assert.deepEqual(h.scrolls, [720])
  130. })
  131. }
  132. test('unrelated customer paths and forged quote return IDs discard the quote snapshot', async () => {
  133. for (const query of [
  134. {},
  135. { from: 'customer', returnQuoteId: 'Q1' },
  136. { from: 'quote', returnQuoteId: '../Q1' }
  137. ]) {
  138. const h = harness('manage'),
  139. first = h.create()
  140. first.keyword.value = 'private'
  141. h.detail()
  142. h.navigate({ path: '/m/crm/customer/C1', query })
  143. h.navigate({ path: '/m/crm/quote/Q1', query: {} })
  144. h.navigate({ path: h.base, query: {} })
  145. const back = h.create()
  146. await back.state.restore()
  147. assert.equal(back.keyword.value, '')
  148. }
  149. })