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

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