mobile-pl-return-retry.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  1. import assert from 'node:assert/strict'
  2. import { readFileSync } from 'node:fs'
  3. import test from 'node:test'
  4. import { parse } from '@vue/compiler-sfc'
  5. import ts from 'typescript'
  6. import { ref, reactive, computed, nextTick, watch, effectScope } from 'vue'
  7. function harness(file: string) {
  8. const base = `/m/pl/${file}`
  9. let path = base
  10. let leave: any
  11. let unmount: any
  12. const hooks = new Set<any>()
  13. const scrolls: number[] = []
  14. const user = reactive({ accessToken: 'a', getUserInfo: { tenantNo: 'a' } })
  15. const bindings = {
  16. ref,
  17. nextTick,
  18. watch,
  19. useRoute: () => ({ path }),
  20. useRouter: () => ({
  21. afterEach: (fn: any) => {
  22. hooks.add(fn)
  23. return () => hooks.delete(fn)
  24. }
  25. }),
  26. useUserStore: () => user,
  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 helper = 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(helper, { target: ts.ScriptTarget.ESNext }) + ';return useListReturnState'
  44. )(...Object.values(bindings))
  45. const descriptor = parse(
  46. readFileSync(new URL(`../src/views/m/pl/${file}/index.vue`, import.meta.url), 'utf8')
  47. ).descriptor
  48. const source = ts.createSourceFile(
  49. 'page.ts',
  50. descriptor.scriptSetup!.content,
  51. ts.ScriptTarget.Latest,
  52. true
  53. )
  54. const scopes: ReturnType<typeof effectScope>[] = []
  55. function create(enableRetry = true) {
  56. const requests: any[] = []
  57. const state: any = {
  58. userStore: user,
  59. page: ref(1),
  60. plans: ref([]),
  61. total: ref(60),
  62. loading: ref(false),
  63. error: ref(''),
  64. forbidden: ref(false),
  65. failedMore: ref(false),
  66. selected: ref(new Set()),
  67. expandedIds: ref(new Set()),
  68. pageSize: 20,
  69. plPlanListTabToFilters: () => ({}),
  70. HttpError: Error,
  71. ApiStatus: { forbidden: 403 },
  72. planApi: {
  73. list: (params: any) =>
  74. new Promise((resolve, reject) => requests.push({ params, resolve, reject }))
  75. }
  76. }
  77. for (const key of [
  78. 'keyword',
  79. 'appliedKeyword',
  80. 'activeTab',
  81. 'dateShortcut',
  82. 'customFrom',
  83. 'customTo',
  84. 'customDateLabel',
  85. 'tab',
  86. 'dateKind',
  87. 'readyKind'
  88. ])
  89. state[key] = ref('')
  90. for (const key of ['dateRange', 'dates']) state[key] = ref([])
  91. state.filterState = ref({})
  92. state.sortState = ref({})
  93. state.hasMore = computed(() => state.plans.value.length < state.total.value)
  94. state.query = () => ({ q: state.appliedKeyword.value, ...state.filterState.value })
  95. const names = ['requestKey', 'buildParams', 'fetchPage', 'load', 'retryLoad']
  96. const code = source.statements
  97. .filter(
  98. (n) =>
  99. (ts.isFunctionDeclaration(n) && names.includes(n.name?.text || '')) ||
  100. (ts.isVariableStatement(n) &&
  101. n.declarationList.declarations.some((d) =>
  102. ['sessionScope', 'listReturnState'].includes(d.name.getText(source))
  103. ))
  104. )
  105. .map((n) => n.getText(source))
  106. .join('\n')
  107. const scope = effectScope()
  108. scopes.push(scope)
  109. const api = scope.run(() =>
  110. new Function(
  111. ...Object.keys(state),
  112. 'useListReturnState',
  113. `let disposed=false;let requestVersion=0;${ts.transpile(code, { target: ts.ScriptTarget.ESNext })};return {state:listReturnState,retry:typeof retryLoad==='function'?retryLoad:undefined,run:${file === 'plan' ? 'fetchPage' : '(reset)=>load(!reset)'}}`
  114. )(...Object.values(state), (options: any) => {
  115. if (!enableRetry) delete options.retryFailedRestore
  116. return useState(options)
  117. })
  118. )
  119. return { ...state, ...api, requests }
  120. }
  121. function navigate(to: string) {
  122. const from = path
  123. path = to
  124. for (const fn of hooks) fn({ path: to }, { path: from })
  125. }
  126. function returned(enableRetry = true) {
  127. const first = create()
  128. first.keyword.value = first.appliedKeyword.value = 'saved'
  129. first.page.value = 3
  130. first.expandedIds.value = new Set(['row-2'])
  131. leave({ path: `${base}/row-2` })
  132. navigate(`${base}/row-2`)
  133. unmount()
  134. navigate(base)
  135. return create(enableRetry)
  136. }
  137. return {
  138. create,
  139. returned,
  140. user,
  141. scrolls,
  142. leave: () => leave({ path: '/workbench' }),
  143. unmount: () => unmount(),
  144. close: () => scopes.forEach((s) => s.stop()),
  145. template: descriptor.template!.content
  146. }
  147. }
  148. async function flush() {
  149. for (let i = 0; i < 8; i++) await nextTick()
  150. }
  151. async function success(h: any, index: number) {
  152. assert.ok(h.requests[index], `request ${index} exists`)
  153. const page = h.requests[index].params.page
  154. h.requests[index].resolve({ list: [{ id: `row-${page}` }], total: 60 })
  155. await flush()
  156. }
  157. for (const file of ['plan', 'plan-cert']) {
  158. for (const failedPage of [1, 2])
  159. test(`${file}: page ${failedPage} restoration failure retries via real page handler then consumes snapshot`, async () => {
  160. const env = harness(file)
  161. try {
  162. const h = env.returned()
  163. const first = h.state.restore()
  164. if (failedPage === 2) await success(h, 0)
  165. h.requests[failedPage - 1].reject(new Error('offline'))
  166. await first
  167. assert.equal(h.keyword.value, 'saved')
  168. assert.equal(h.state.restoring.value, false)
  169. assert.equal(typeof h.retry, 'function', 'page must route retry through restoration')
  170. assert.match(env.template, /@click="retryLoad"/)
  171. const start = h.requests.length
  172. const retry = h.retry()
  173. await flush()
  174. for (let i = 0; i < 3; i++) await success(h, start + i)
  175. await retry
  176. assert.equal(h.page.value, 3)
  177. assert.deepEqual([...h.expandedIds.value], ['row-2'])
  178. assert.deepEqual(env.scrolls, [720])
  179. const ordinary = h.retry()
  180. await flush()
  181. await success(h, h.requests.length - 1)
  182. await ordinary
  183. assert.equal(h.page.value, 4)
  184. assert.deepEqual(env.scrolls, [720])
  185. } finally {
  186. env.close()
  187. }
  188. })
  189. test(`${file}: duplicate retry does not create requests or release restoring`, async () => {
  190. const env = harness(file)
  191. try {
  192. const h = env.returned()
  193. const first = h.state.restore()
  194. h.requests[0].reject(new Error('offline'))
  195. await first
  196. assert.equal(typeof h.retry, 'function')
  197. const retry = h.retry()
  198. await flush()
  199. await h.retry()
  200. assert.equal(h.requests.length, 2)
  201. assert.equal(h.state.restoring.value, true)
  202. for (let i = 1; i <= 3; i++) await success(h, i)
  203. await retry
  204. assert.deepEqual(env.scrolls, [720])
  205. } finally {
  206. env.close()
  207. }
  208. })
  209. for (const change of ['query', 'account', 'tenant', 'unmount', 'leave'])
  210. test(`${file}: ${change} invalidates failed snapshot permanently`, async () => {
  211. const env = harness(file)
  212. try {
  213. const h = env.returned()
  214. const first = h.state.restore()
  215. h.requests[0].reject(new Error('offline'))
  216. await first
  217. if (change === 'query') {
  218. h.keyword.value = 'new'
  219. h.keyword.value = 'saved'
  220. }
  221. if (change === 'account') {
  222. env.user.accessToken = 'b'
  223. env.user.accessToken = 'a'
  224. }
  225. if (change === 'tenant') {
  226. env.user.getUserInfo.tenantNo = 'b'
  227. env.user.getUserInfo.tenantNo = 'a'
  228. }
  229. if (change === 'unmount') env.unmount()
  230. if (change === 'leave') env.leave()
  231. assert.equal(typeof h.retry, 'function')
  232. const retry = h.retry()
  233. await flush()
  234. if (h.requests.length > 1) await success(h, 1)
  235. await retry
  236. assert.ok(h.requests.length <= 2)
  237. assert.deepEqual(env.scrolls, [])
  238. assert.deepEqual([...h.expandedIds.value], [])
  239. } finally {
  240. env.close()
  241. }
  242. })
  243. test(`${file}: obsolete finally cannot release a newer restore`, async () => {
  244. const env = harness(file)
  245. try {
  246. const h = env.returned()
  247. const old = h.state.restore()
  248. h.keyword.value = 'new'
  249. const fresh = h.state.restore()
  250. h.requests[0].reject(new Error('old'))
  251. await old
  252. assert.equal(h.state.restoring.value, true)
  253. await success(h, 1)
  254. await fresh
  255. assert.equal(h.state.restoring.value, false)
  256. assert.deepEqual(env.scrolls, [])
  257. } finally {
  258. env.close()
  259. }
  260. })
  261. for (const more of [false, true])
  262. test(`${file}: ordinary ${more ? 'pagination' : 'initial'} retry keeps existing semantics`, async () => {
  263. const env = harness(file)
  264. try {
  265. const h = env.create()
  266. const failed = h.run(!more)
  267. h.requests[0].reject(new Error('offline'))
  268. await failed
  269. assert.equal(typeof h.retry, 'function')
  270. const retry = h.retry()
  271. await flush()
  272. assert.equal(h.requests[1].params.page, more ? 2 : 1)
  273. await success(h, 1)
  274. await retry
  275. assert.deepEqual(env.scrolls, [])
  276. } finally {
  277. env.close()
  278. }
  279. })
  280. }
  281. for (const file of ['plan', 'plan-cert']) {
  282. test(`${file}: absent opt-in retains one-shot CRM default restoration`, async () => {
  283. const env = harness(file)
  284. try {
  285. const h = env.returned(false)
  286. const first = h.state.restore()
  287. await success(h, 0)
  288. h.requests[1].reject(new Error('offline'))
  289. await first
  290. const retry = h.retry()
  291. await success(h, 2)
  292. await retry
  293. assert.equal(h.page.value, 2)
  294. assert.equal(h.requests.length, 3)
  295. assert.deepEqual([...h.expandedIds.value], [])
  296. assert.deepEqual(env.scrolls, [])
  297. } finally {
  298. env.close()
  299. }
  300. })
  301. for (const invalidation of ['query', 'unmount', 'leave']) {
  302. test(`${file}: pending retry ${invalidation} cannot restore expansion or scroll`, async () => {
  303. const env = harness(file)
  304. try {
  305. const h = env.returned()
  306. const first = h.state.restore()
  307. h.requests[0].reject(new Error('offline'))
  308. await first
  309. const retry = h.retry()
  310. if (invalidation === 'query') {
  311. h.keyword.value = 'new'
  312. h.keyword.value = 'saved'
  313. }
  314. if (invalidation === 'unmount') env.unmount()
  315. if (invalidation === 'leave') env.leave()
  316. await success(h, 1)
  317. await retry
  318. assert.equal(h.requests.length, 2)
  319. assert.deepEqual([...h.expandedIds.value], [])
  320. assert.deepEqual(env.scrolls, [])
  321. assert.equal(h.state.restoring.value, false)
  322. } finally {
  323. env.close()
  324. }
  325. })
  326. }
  327. }
  328. for (const file of ['plan', 'plan-cert']) {
  329. test(`${file}: equivalent user info replacement preserves failed return snapshot`, async () => {
  330. const env = harness(file)
  331. try {
  332. const h = env.returned()
  333. const first = h.state.restore()
  334. h.requests[0].reject(new Error('offline'))
  335. await first
  336. env.user.getUserInfo = { tenantNo: 'a' }
  337. const retry = h.retry()
  338. for (let i = 1; i <= 3; i++) await success(h, i)
  339. await retry
  340. assert.equal(h.page.value, 3)
  341. assert.deepEqual([...h.expandedIds.value], ['row-2'])
  342. assert.deepEqual(env.scrolls, [720])
  343. } finally {
  344. env.close()
  345. }
  346. })
  347. }