mobile-crm-list-state.test.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520
  1. import assert from 'node:assert/strict'
  2. import { readFileSync } from 'node:fs'
  3. import test from 'node:test'
  4. import { compile, createRenderer, defineComponent, h, nextTick, reactive, ref } from 'vue'
  5. import { parse } from '@vue/compiler-sfc'
  6. import ts from 'typescript'
  7. const pages = [
  8. 'customer/index.vue',
  9. 'product/index.vue',
  10. 'quote/MpQuoteList.vue',
  11. 'contract/MpContractList.vue'
  12. ]
  13. const root = new URL('../src/views/m/crm/', import.meta.url)
  14. // Vue's real compiler and renderer run against in-memory host nodes. No browser is launched.
  15. function mountTemplate(file: string, mode = 'manage') {
  16. type Node = { type: string; children: Node[]; parent?: Node; props: Record<string, any> }
  17. const node = (type: string): Node =>
  18. ({
  19. type,
  20. children: [],
  21. props: {},
  22. style: {},
  23. addEventListener() {},
  24. removeEventListener() {}
  25. }) as Node
  26. const renderer = createRenderer<Node, Node>({
  27. createElement: node,
  28. createText: node,
  29. createComment: node,
  30. setText() {},
  31. setElementText() {},
  32. parentNode: (el) => el.parent!,
  33. nextSibling: (el) => el.parent?.children[el.parent.children.indexOf(el) + 1] || null,
  34. patchProp: (el, key, _old, value) => {
  35. el.props[key] = value
  36. },
  37. insert(el, parent, anchor) {
  38. if (el.parent) el.parent.children.splice(el.parent.children.indexOf(el), 1)
  39. el.parent = parent
  40. const index = anchor ? parent.children.indexOf(anchor) : -1
  41. parent.children.splice(index < 0 ? parent.children.length : index, 0, el)
  42. },
  43. remove(el) {
  44. el.parent?.children.splice(el.parent.children.indexOf(el), 1)
  45. }
  46. })
  47. const state = reactive<Record<string, any>>({
  48. loading: false,
  49. forbidden: false,
  50. keyword: '山峡',
  51. rows: [],
  52. products: [],
  53. cards: [],
  54. views: [],
  55. viewTabs: [],
  56. selected: [],
  57. selectedIds: new Set(),
  58. previewFields: [],
  59. filtersOpen: true,
  60. moreOpen: true,
  61. submitForm: { approverIds: [] },
  62. moreDraft: {},
  63. mode,
  64. error: '',
  65. errorText: ''
  66. })
  67. const fallback = () => []
  68. const context = new Proxy(state, {
  69. has: (_, key) =>
  70. typeof key === 'string' &&
  71. !key.startsWith('_') &&
  72. !['String', 'Boolean', 'Number'].includes(key),
  73. get: (target, key) =>
  74. key === Symbol.unscopables ? undefined : key in target ? target[key as string] : fallback
  75. })
  76. const { descriptor } = parse(readFileSync(new URL(file, root), 'utf8'))
  77. const render = compile(descriptor.template!.content)
  78. const passthrough = defineComponent({
  79. setup:
  80. (_, { slots }) =>
  81. () =>
  82. h(
  83. 'stub',
  84. Object.values(slots).flatMap((slot) => slot?.())
  85. )
  86. })
  87. const header = defineComponent({
  88. setup:
  89. (_, { slots }) =>
  90. () =>
  91. h(
  92. 'input',
  93. Object.values(slots).flatMap((slot) => slot?.())
  94. )
  95. })
  96. const app = renderer.createApp({ render: () => render.call(context, context, []) })
  97. const tags = [...descriptor.template!.content.matchAll(/<([A-Z][\w]+|van-[\w-]+)/g)].map(
  98. (match) => match[1]
  99. )
  100. for (const tag of new Set(tags)) {
  101. const type =
  102. tag === 'CustomerFilters'
  103. ? 'filters'
  104. : tag === 'van-popup'
  105. ? 'popup'
  106. : tag === 'MpListPageSkeleton'
  107. ? 'skeleton'
  108. : ''
  109. app.component(
  110. tag,
  111. tag === 'MpBusinessHeader'
  112. ? header
  113. : type
  114. ? defineComponent({
  115. setup:
  116. (_, { slots }) =>
  117. () =>
  118. h(type, slots.default?.())
  119. })
  120. : passthrough
  121. )
  122. }
  123. const container = node('root')
  124. app.mount(container)
  125. const find = (type: string, start = container): Node | undefined => {
  126. if (start.type === type) return start
  127. for (const child of start.children) {
  128. const found = find(type, child)
  129. if (found) return found
  130. }
  131. }
  132. return { state, find, app }
  133. }
  134. for (const { page, mode } of pages.flatMap((page) =>
  135. (page.includes('Mp') ? ['manage', 'approve'] : ['manage']).map((mode) => ({ page, mode }))
  136. )) {
  137. test(`${page} (${mode}): search retains the same header and filter nodes while results reload`, async () => {
  138. const { state, find, app } = mountTemplate(page, mode)
  139. const input = find('input')
  140. assert.ok(input)
  141. const filterType = page.startsWith('customer') ? 'filters' : 'popup'
  142. const filters = find(filterType)
  143. assert.ok(filters)
  144. state.loading = true
  145. await nextTick()
  146. assert.ok(find('input') === input, 'refresh must not unmount the search input')
  147. assert.ok(find(filterType) === filters, 'open filter contents must stay mounted')
  148. assert.ok(find('skeleton'), 'loading still shows a body skeleton')
  149. state.loading = false
  150. await nextTick()
  151. assert.ok(find('input') === input)
  152. state.forbidden = true
  153. await nextTick()
  154. assert.ok(find('input'), '403 keeps navigation available')
  155. app.unmount()
  156. })
  157. }
  158. function sourceFunction(
  159. file: string,
  160. name: string,
  161. bindings: Record<string, any>,
  162. dependencies: string[] = []
  163. ) {
  164. const { descriptor } = parse(readFileSync(new URL(file, root), 'utf8'))
  165. const source = ts.createSourceFile(
  166. file,
  167. descriptor.scriptSetup!.content,
  168. ts.ScriptTarget.Latest,
  169. true
  170. )
  171. const declarations = source.statements.filter(
  172. (item) =>
  173. ts.isFunctionDeclaration(item) && [name, ...dependencies].includes(item.name?.text || '')
  174. )
  175. const code = ts.transpile(declarations.map((item) => item.getText(source)).join('\n'), {
  176. target: ts.ScriptTarget.ESNext
  177. })
  178. return new Function(...Object.keys(bindings), `${code}; return ${name}`)(
  179. ...Object.values(bindings)
  180. )
  181. }
  182. for (const page of pages) {
  183. test(`${page}: shell ready restores the saved query instead of clearing it`, async () => {
  184. let restores = 0
  185. const bindings: Record<string, any> = {
  186. props: { mode: 'manage' },
  187. categoryRequestSeq: 0,
  188. modelRequestSeq: 0,
  189. categoryMapLoaded: false,
  190. modelOptionsLoaded: false,
  191. reload() {},
  192. clearSelection() {},
  193. listReturnState: {
  194. restore: async () => {
  195. restores++
  196. }
  197. }
  198. }
  199. for (const key of [
  200. 'keyword',
  201. 'categoryId',
  202. 'model',
  203. 'priceMin',
  204. 'priceMax',
  205. 'view',
  206. 'quoteType',
  207. 'contractType',
  208. 'amountRange',
  209. 'quoteDateBegin',
  210. 'quoteDateEnd',
  211. 'signDateBegin',
  212. 'signDateEnd',
  213. 'minAmount',
  214. 'maxAmount',
  215. 'speedOpen',
  216. 'selectMode',
  217. 'expandedIds'
  218. ]) {
  219. bindings[key] = { value: key === 'keyword' ? '山峡' : '' }
  220. }
  221. await sourceFunction(page, 'onShellReady', bindings)()
  222. assert.equal(bindings.keyword.value, '山峡')
  223. assert.equal(restores, 1, 'ready must restore list context after authentication')
  224. })
  225. }
  226. function returnStateHarness() {
  227. let path = '/m/crm/customer'
  228. let leave: (to: { path: string }) => void
  229. let unmount: () => void
  230. const afterEach = new Set<(to: { path: string }, from: { path: string }) => void>()
  231. const user = {
  232. accessToken: 'session-a',
  233. getUserInfo: { userId: 'a', tenantNo: 'tenant-a', roles: ['sales'] }
  234. }
  235. const scrolls: number[] = []
  236. const bindings = {
  237. ref,
  238. nextTick,
  239. useRoute: () => ({ path }),
  240. useRouter: () => ({
  241. afterEach(fn: any) {
  242. afterEach.add(fn)
  243. return () => afterEach.delete(fn)
  244. }
  245. }),
  246. useUserStore: () => user,
  247. onBeforeRouteLeave: (fn: typeof leave) => {
  248. leave = fn
  249. },
  250. onBeforeUnmount: (fn: typeof unmount) => {
  251. unmount = fn
  252. },
  253. window: {
  254. scrollY: 720,
  255. scrollTo: ({ top }: { top: number }) => {
  256. scrolls.push(top)
  257. }
  258. }
  259. }
  260. const source = readFileSync(new URL('useListReturnState.ts', root), 'utf8')
  261. .replace(/^import .*$/gm, '')
  262. .replace('export function', 'function')
  263. const code = ts.transpile(source, { target: ts.ScriptTarget.ESNext })
  264. const useState = new Function(...Object.keys(bindings), `${code}; return useListReturnState`)(
  265. ...Object.values(bindings)
  266. )
  267. function create() {
  268. const keyword = ref('')
  269. const query = ref<Record<string, string>>({})
  270. const page = ref(3)
  271. const expandedIds = ref(new Set(['customer-2']))
  272. const hasMore = ref(true)
  273. const calls: number[] = []
  274. let failPage = 0
  275. let duringFetch: (() => void) | undefined
  276. const state = useState({
  277. fields: { keyword, query },
  278. page,
  279. expandedIds,
  280. hasMore,
  281. queryKey: () => JSON.stringify([keyword.value, query.value]),
  282. async fetchPage(reset: boolean) {
  283. const requested = reset ? 1 : page.value + 1
  284. calls.push(requested)
  285. if (requested === failPage) return
  286. page.value = requested
  287. expandedIds.value = new Set()
  288. duringFetch?.()
  289. return true
  290. }
  291. })
  292. return {
  293. state,
  294. keyword,
  295. query,
  296. page,
  297. expandedIds,
  298. calls,
  299. failAt: (value: number) => {
  300. failPage = value
  301. },
  302. duringFetch: (fn: () => void) => {
  303. duringFetch = fn
  304. }
  305. }
  306. }
  307. function navigate(to: string) {
  308. const from = path
  309. path = to
  310. for (const fn of afterEach) fn({ path: to }, { path: from })
  311. }
  312. function openDetail() {
  313. leave({ path: '/m/crm/customer/customer-2' })
  314. navigate('/m/crm/customer/customer-2')
  315. unmount()
  316. }
  317. return { create, navigate, openDetail, user, scrolls }
  318. }
  319. test('return restores filters, re-fetches loaded pages and restores expanded cards and scroll', async () => {
  320. const env = returnStateHarness()
  321. const list = env.create()
  322. list.keyword.value = '山峡'
  323. list.query.value = { levelIn: 'A' }
  324. env.openDetail()
  325. list.query.value.levelIn = 'B'
  326. env.navigate('/m/crm/customer')
  327. const returned = env.create()
  328. await returned.state.restore()
  329. assert.equal(returned.keyword.value, '山峡')
  330. assert.deepEqual(returned.query.value, { levelIn: 'A' })
  331. assert.deepEqual(returned.calls, [1, 2, 3])
  332. assert.deepEqual([...returned.expandedIds.value], ['customer-2'])
  333. assert.deepEqual(env.scrolls, [720])
  334. assert.equal(returned.state.restoring.value, false)
  335. })
  336. for (const change of ['session', 'user', 'tenant', 'roles', 'outside', 'approval'] as const) {
  337. test(`saved state is not reused after ${change} changes`, async () => {
  338. const env = returnStateHarness()
  339. const list = env.create()
  340. list.keyword.value = 'private keyword'
  341. env.openDetail()
  342. if (change === 'session') env.user.accessToken = 'session-b'
  343. if (change === 'user') env.user.getUserInfo.userId = 'b'
  344. if (change === 'tenant') env.user.getUserInfo.tenantNo = 'tenant-b'
  345. if (change === 'roles') env.user.getUserInfo.roles = ['viewer']
  346. if (change === 'outside') env.navigate('/workbench')
  347. env.navigate(change === 'approval' ? '/m/crm/customer/approval' : '/m/crm/customer')
  348. const returned = env.create()
  349. await returned.state.restore()
  350. assert.equal(returned.keyword.value, '')
  351. assert.deepEqual(returned.calls, [1])
  352. assert.deepEqual(env.scrolls, [])
  353. })
  354. }
  355. test('failed page during restoration stops without skipping it or jumping to an invalid position', async () => {
  356. const env = returnStateHarness()
  357. env.create()
  358. env.openDetail()
  359. env.navigate('/m/crm/customer')
  360. const returned = env.create()
  361. returned.failAt(2)
  362. await returned.state.restore()
  363. assert.deepEqual(returned.calls, [1, 2])
  364. assert.equal(returned.page.value, 1)
  365. assert.deepEqual(env.scrolls, [])
  366. assert.equal(returned.state.restoring.value, false)
  367. })
  368. test('typing a new query during restoration cancels old pagination and scroll restoration', async () => {
  369. const env = returnStateHarness()
  370. env.create()
  371. env.openDetail()
  372. env.navigate('/m/crm/customer')
  373. const returned = env.create()
  374. returned.duringFetch(() => {
  375. returned.keyword.value = 'new query'
  376. })
  377. await returned.state.restore()
  378. assert.deepEqual(returned.calls, [1])
  379. assert.deepEqual(env.scrolls, [])
  380. })
  381. class ForbiddenError extends Error {
  382. code = 403
  383. }
  384. function requestHarness(file: string) {
  385. const requests: Array<{
  386. params: any
  387. resolve: (value: any) => void
  388. reject: (error: Error) => void
  389. }> = []
  390. const request = (params: any) =>
  391. new Promise((resolve, reject) => {
  392. requests.push({ params, resolve, reject })
  393. })
  394. const rows = ref([{ id: 'old' }])
  395. const page = ref(1)
  396. const state: Record<string, any> = {
  397. rows,
  398. products: rows,
  399. quotes: rows,
  400. contracts: rows,
  401. page,
  402. pageNum: page,
  403. props: { mode: 'manage' },
  404. requestSeq: 0,
  405. pageSize: 20,
  406. HttpError: ForbiddenError,
  407. ApiStatus: { forbidden: 403 },
  408. ElMessage: { error() {} },
  409. fetchCustomerList: request,
  410. fetchMyCustomerList: request,
  411. fetchToFollowCustomerList: request,
  412. fetchKeyCustomerList: request,
  413. fetchProductList: request,
  414. listQuote: request,
  415. listContract: request,
  416. ensureCategoryPathMap: async () => {},
  417. ensureModelOptions: async () => {},
  418. resolveCurrentUserId: () => 'user-a',
  419. clearSelection() {},
  420. buildProductListQuery: (query: any) => query,
  421. buildQuoteListQuery: (query: any) => query,
  422. buildContractListQuery: (query: any) => query,
  423. unwrapData: (data: any) => data,
  424. unwrapRows: (data: any) => data.rows,
  425. selected: ref([]),
  426. expandedIds: ref(new Set()),
  427. query: ref({}),
  428. total: ref(40),
  429. loading: ref(false),
  430. forbidden: ref(false),
  431. activeView: ref('all')
  432. }
  433. for (const key of [
  434. 'keyword',
  435. 'categoryId',
  436. 'model',
  437. 'priceMin',
  438. 'priceMax',
  439. 'view',
  440. 'quoteType',
  441. 'contractType',
  442. 'quoteDateBegin',
  443. 'quoteDateEnd',
  444. 'signDateBegin',
  445. 'signDateEnd',
  446. 'minAmount',
  447. 'maxAmount',
  448. 'error',
  449. 'errorText',
  450. 'failedRequest',
  451. 'filtersOpen',
  452. 'poolOpen',
  453. 'speedOpen',
  454. 'selectMode'
  455. ])
  456. state[key] = ref('')
  457. const fetchPage = sourceFunction(file, 'fetchPage', state, ['requestSnapshot'])
  458. const flush = () => new Promise<void>((resolve) => setImmediate(resolve))
  459. return { state, requests, fetchPage, flush }
  460. }
  461. for (const file of pages) {
  462. test(`${file}: newest search wins and a failed refresh cannot display stale rows`, async () => {
  463. const { state, requests, fetchPage, flush } = requestHarness(file)
  464. state.keyword.value = 'old query'
  465. const old = fetchPage(true)
  466. await flush()
  467. assert.deepEqual(state.rows.value, [])
  468. state.keyword.value = 'new query'
  469. const latest = fetchPage(true)
  470. await flush()
  471. requests[1].resolve({ rows: [{ id: 'new' }], total: 1 })
  472. assert.equal(await latest, true)
  473. requests[0].resolve({ rows: [{ id: 'old' }], total: 1 })
  474. await old
  475. assert.deepEqual(state.rows.value, [{ id: 'new' }])
  476. const failed = fetchPage(true)
  477. await flush()
  478. requests[2].reject(new Error('offline'))
  479. await failed
  480. assert.deepEqual(state.rows.value, [])
  481. assert.equal(state.loading.value, false)
  482. })
  483. test(`${file}: a failed next page retries the same page and 403 removes business rows`, async () => {
  484. const { state, requests, fetchPage, flush } = requestHarness(file)
  485. const failed = fetchPage(false)
  486. await flush()
  487. requests[0].reject(new Error('offline'))
  488. await failed
  489. assert.equal(state.page.value, 1)
  490. assert.deepEqual(state.rows.value, [{ id: 'old' }])
  491. const retry = fetchPage(false)
  492. await flush()
  493. assert.equal(requests[0].params.page ?? requests[0].params.pageNum, 2)
  494. assert.equal(requests[1].params.page ?? requests[1].params.pageNum, 2)
  495. requests[1].resolve({ rows: [{ id: 'page-2' }], total: 40 })
  496. assert.equal(await retry, true)
  497. assert.equal(state.page.value, 2)
  498. assert.deepEqual(state.rows.value, [{ id: 'old' }, { id: 'page-2' }])
  499. const forbidden = fetchPage(true)
  500. await flush()
  501. requests[2].reject(new ForbiddenError('forbidden'))
  502. await forbidden
  503. assert.equal(state.forbidden.value, true)
  504. assert.deepEqual(state.rows.value, [])
  505. })
  506. }