mobile-crm-detail-navigation.test.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323
  1. import assert from 'node:assert/strict'
  2. import test from 'node:test'
  3. import { readFileSync } from 'node:fs'
  4. import { parse } from '@vue/compiler-sfc'
  5. import ts from 'typescript'
  6. import { computed, ref } from 'vue'
  7. // Execute the production navigation declarations and lifecycle hooks; only browser
  8. // geometry and time are controlled, so these tests do not mirror the scroll algorithm.
  9. function harness(page: 'quote' | 'contract') {
  10. const source = parse(
  11. readFileSync(new URL(`../src/views/m/crm/${page}/detail.vue`, import.meta.url), 'utf8')
  12. ).descriptor.scriptSetup!.content
  13. const ast = ts.createSourceFile('detail.ts', source, ts.ScriptTarget.Latest, true)
  14. const names = new Set([
  15. 'activeTab',
  16. 'detailTabs',
  17. 'TAB_BAR_HEIGHT',
  18. 'stickyOffset',
  19. 'scrollMargin',
  20. 'scrollLockUntil',
  21. 'scrollLockTimer',
  22. 'currentRecordId',
  23. 'navigationReady',
  24. 'syncStickyOffset',
  25. 'syncActiveTabFromScroll',
  26. 'onDetailScroll',
  27. 'onAnchorTab'
  28. ])
  29. const code = ast.statements
  30. .filter((statement) => {
  31. if (ts.isFunctionDeclaration(statement)) return names.has(statement.name!.text)
  32. if (ts.isVariableStatement(statement))
  33. return statement.declarationList.declarations.some((d) => names.has(d.name.getText(ast)))
  34. return (
  35. ts.isExpressionStatement(statement) &&
  36. ts.isCallExpression(statement.expression) &&
  37. ['onMounted', 'onUnmounted'].includes(statement.expression.expression.getText(ast))
  38. )
  39. })
  40. .map((statement) => statement.getText(ast))
  41. .join('\n')
  42. const mounts: Array<() => void> = [],
  43. unmounts: Array<() => void> = []
  44. const listeners = new Map<string, () => void>()
  45. const timers = new Map<number, { due: number; callback: () => void }>()
  46. const scrolls: any[] = []
  47. let now = 1000,
  48. timerId = 0
  49. const record = ref<any>({ id: 'A' }),
  50. loading = ref(false)
  51. const route = {
  52. name: page === 'quote' ? 'MpCrmQuoteDetail' : 'MpCrmContractDetail',
  53. get path() {
  54. return `/m/crm/${page}/${this.params.id}`
  55. },
  56. params: { id: 'A' }
  57. }
  58. const state = { disposed: false, shellReady: true, detailRequestSeq: 0, categoryRequestSeq: 0 }
  59. const last = page === 'quote' ? 'approval' : 'attachments'
  60. const tops: Record<string, number> = {
  61. [`${page}-sec-base`]: 100,
  62. [`${page}-sec-amount`]: 350,
  63. [`${page}-sec-products`]: 700,
  64. [`${page}-sec-${last}`]: 1300
  65. }
  66. const viewport = {
  67. scrollY: 0,
  68. innerHeight: 600,
  69. addEventListener: (name: string, fn: () => void) => listeners.set(name, fn),
  70. removeEventListener: (name: string, fn: () => void) => {
  71. assert.equal(listeners.get(name), fn)
  72. listeners.delete(name)
  73. },
  74. setTimeout: (callback: () => void, delay: number) => {
  75. timers.set(++timerId, { due: now + delay, callback })
  76. return timerId
  77. },
  78. clearTimeout: (id: number) => timers.delete(id)
  79. }
  80. const document = {
  81. scrollingElement: { scrollHeight: 1600 },
  82. querySelector: () => ({ getBoundingClientRect: () => ({ height: 56 }) }),
  83. getElementById: (id: string) =>
  84. id in tops
  85. ? {
  86. getBoundingClientRect: () => ({ top: tops[id] - viewport.scrollY }),
  87. scrollIntoView: (options: any) => scrolls.push({ id, options })
  88. }
  89. : null
  90. }
  91. const bindings = {
  92. props: { embedded: false },
  93. ref,
  94. computed,
  95. [page]: record,
  96. loading,
  97. route,
  98. window: viewport,
  99. document,
  100. Date: { now: () => now },
  101. onMounted: (fn: () => void) => mounts.push(fn),
  102. onUnmounted: (fn: () => void) => unmounts.push(fn)
  103. }
  104. const execute = new Function(
  105. 'state',
  106. ...Object.keys(bindings),
  107. `with (state) {
  108. ${ts.transpile(code, { target: ts.ScriptTarget.ESNext })}
  109. return { activeTab, onAnchorTab, sync: syncActiveTabFromScroll }
  110. }`
  111. )
  112. return {
  113. ...execute(state, ...Object.values(bindings)),
  114. state,
  115. record,
  116. loading,
  117. route,
  118. viewport,
  119. document,
  120. tops,
  121. scrolls,
  122. timers,
  123. last,
  124. mount: () => mounts.forEach((fn) => fn()),
  125. unmount: () => unmounts.forEach((fn) => fn()),
  126. scroll: () => listeners.get('scroll')!(),
  127. tick: (ms: number) => {
  128. now += ms
  129. for (const [id, timer] of [...timers]) {
  130. if (timer.due > now) continue
  131. timers.delete(id)
  132. timer.callback()
  133. }
  134. }
  135. }
  136. }
  137. for (const page of ['quote'] as const) {
  138. test(`${page}: manual scroll and Home follow visible sections`, () => {
  139. const h = harness(page)
  140. h.viewport.scrollY = 650
  141. h.sync()
  142. assert.equal(h.activeTab.value, 'products')
  143. h.viewport.scrollY = 0
  144. h.sync()
  145. assert.equal(h.activeTab.value, 'base')
  146. })
  147. test(`${page}: bottom selects the visible short final section`, () => {
  148. const h = harness(page)
  149. h.viewport.scrollY = 1000
  150. h.sync()
  151. assert.equal(h.activeTab.value, h.last)
  152. })
  153. for (const edge of ['short', 'missing', 'below'])
  154. test(`${page}: ${edge} final section does not force bottom selection`, () => {
  155. const h = harness(page)
  156. if (edge === 'short') h.document.scrollingElement.scrollHeight = 500
  157. else {
  158. h.viewport.scrollY = 1000
  159. if (edge === 'missing') delete h.tops[`${page}-sec-${h.last}`]
  160. else h.tops[`${page}-sec-${h.last}`] = 1800
  161. }
  162. h.sync()
  163. assert.equal(h.activeTab.value, edge === 'short' ? 'base' : 'products')
  164. })
  165. test(`${page}: Home within click lock resyncs at 700ms without another scroll`, () => {
  166. const h = harness(page)
  167. h.mount()
  168. h.onAnchorTab({ name: h.last })
  169. assert.deepEqual(h.scrolls, [
  170. {
  171. id: `${page}-sec-${h.last}`,
  172. options: { behavior: 'smooth', block: 'start' }
  173. }
  174. ])
  175. h.viewport.scrollY = 1000
  176. h.tick(400)
  177. h.viewport.scrollY = 0
  178. h.scroll()
  179. h.tick(299)
  180. assert.equal(h.activeTab.value, h.last)
  181. h.tick(1)
  182. assert.equal(h.activeTab.value, 'base')
  183. assert.equal(h.timers.size, 0)
  184. })
  185. test(`${page}: consecutive clicks replace the lock timer`, () => {
  186. const h = harness(page)
  187. h.onAnchorTab({ name: h.last })
  188. h.tick(400)
  189. h.onAnchorTab({ name: 'products' })
  190. assert.equal(h.timers.size, 1)
  191. h.tick(300)
  192. assert.equal(h.activeTab.value, 'products')
  193. assert.equal(h.timers.size, 1)
  194. h.tick(400)
  195. assert.equal(h.activeTab.value, 'base')
  196. assert.equal(h.timers.size, 0)
  197. })
  198. test(`${page}: unmount cancels the pending timer`, () => {
  199. const h = harness(page)
  200. h.mount()
  201. h.onAnchorTab({ name: h.last })
  202. assert.equal(h.timers.size, 1)
  203. h.unmount()
  204. assert.equal(h.timers.size, 0)
  205. h.document.getElementById = () => assert.fail('unmounted timer reads DOM')
  206. h.tick(700)
  207. })
  208. for (const invalid of ['disposed', 'loading', 'notReady', 'mismatch', 'empty', 'missingId'])
  209. test(`${page}: ${invalid} record cannot navigate or read stale anchors`, () => {
  210. const h = harness(page)
  211. h.onAnchorTab({ name: h.last })
  212. if (invalid === 'disposed') h.state.disposed = true
  213. if (invalid === 'loading') h.loading.value = true
  214. if (invalid === 'notReady') h.state.shellReady = false
  215. if (invalid === 'mismatch') h.route.params.id = 'B'
  216. if (invalid === 'empty') h.record.value = null
  217. if (invalid === 'missingId') h.route.params.id = ''
  218. h.document.getElementById = () => assert.fail('invalid record reads stale DOM')
  219. h.tick(700)
  220. h.sync()
  221. h.onAnchorTab({ name: 'products' })
  222. assert.equal(h.activeTab.value, h.last)
  223. assert.equal(h.scrolls.length, 1)
  224. })
  225. test(`${page}: unknown or missing anchor leaves selection unchanged`, () => {
  226. const h = harness(page)
  227. h.onAnchorTab({ name: 'unknown' })
  228. delete h.tops[`${page}-sec-${h.last}`]
  229. h.onAnchorTab({ name: h.last })
  230. assert.equal(h.activeTab.value, 'base')
  231. assert.equal(h.scrolls.length, 0)
  232. })
  233. }
  234. function contractTabsHarness(list: (query: any) => Promise<any>) {
  235. const descriptor = parse(
  236. readFileSync(new URL('../src/views/m/crm/contract/detail.vue', import.meta.url), 'utf8')
  237. ).descriptor
  238. const tree = ts.createSourceFile(
  239. 'contract.ts',
  240. descriptor.scriptSetup!.content,
  241. ts.ScriptTarget.Latest,
  242. true
  243. )
  244. const names = [
  245. 'activeTab',
  246. 'detailTabs',
  247. 'linkedPlans',
  248. 'linkedPlansTotal',
  249. 'linkedPlansLoading',
  250. 'linkedPlansError',
  251. 'linkedPlansRequestSeq',
  252. 'onDetailTabChange',
  253. 'loadLinkedPlans'
  254. ]
  255. const declarations = tree.statements
  256. .filter((node) =>
  257. ts.isFunctionDeclaration(node)
  258. ? names.includes(node.name!.text)
  259. : ts.isVariableStatement(node) &&
  260. node.declarationList.declarations.some((d) => names.includes(d.name.getText(tree)))
  261. )
  262. .map((node) => node.getText(tree))
  263. .join('\n')
  264. const scrolls: any[] = []
  265. const bindings = {
  266. ref,
  267. props: { embedded: false },
  268. contract: ref({ id: 'A' }),
  269. planApi: { list },
  270. window: { scrollTo: (options: any) => scrolls.push(options) }
  271. }
  272. const state = new Function(
  273. ...Object.keys(bindings),
  274. ts.transpile(declarations, { target: ts.ScriptTarget.ESNext }) + `;return {${names.join(',')}}`
  275. )(...Object.values(bindings))
  276. return { ...state, scrolls }
  277. }
  278. test('contract detail uses six business panes and resets scroll when switching panes', () => {
  279. let requests = 0
  280. const h = contractTabsHarness(async () => {
  281. requests++
  282. return { list: [] }
  283. })
  284. assert.equal(h.activeTab.value, 'contract')
  285. assert.deepEqual(
  286. h.detailTabs.map((tab: any) => tab.key),
  287. ['contract', 'plans', 'payment', 'invoice', 'approval', 'operation']
  288. )
  289. for (const key of ['payment', 'invoice', 'approval', 'operation', 'contract']) {
  290. h.onDetailTabChange(key)
  291. assert.equal(h.activeTab.value, key)
  292. }
  293. assert.equal(requests, 0)
  294. assert.deepEqual(h.scrolls, Array(5).fill({ top: 0, behavior: 'auto' }))
  295. })
  296. test('contract plans pane loads lazily without duplicating pending or cached requests', async () => {
  297. let resolve!: (response: any) => void
  298. const requests: any[] = []
  299. const h = contractTabsHarness((query) => {
  300. requests.push(query)
  301. return new Promise((r) => {
  302. resolve = r
  303. })
  304. })
  305. h.onDetailTabChange('plans')
  306. assert.equal(h.linkedPlansLoading.value, true)
  307. h.onDetailTabChange('contract')
  308. h.onDetailTabChange('plans')
  309. assert.deepEqual(requests, [{ contractId: 'A', page: 1, pageSize: 50 }])
  310. resolve({ data: { list: [{ id: 'P1' }], total: 1 } })
  311. await Promise.resolve()
  312. await Promise.resolve()
  313. assert.equal(h.linkedPlansLoading.value, false)
  314. assert.equal(h.linkedPlans.value[0].id, 'P1')
  315. h.onDetailTabChange('contract')
  316. h.onDetailTabChange('plans')
  317. assert.equal(requests.length, 1)
  318. })