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

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