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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  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 = { params: { id: 'A' } }
  52. const state = { disposed: false, shellReady: true, detailRequestSeq: 0, categoryRequestSeq: 0 }
  53. const last = page === 'quote' ? 'approval' : 'attachments'
  54. const tops: Record<string, number> = {
  55. [`${page}-sec-base`]: 100,
  56. [`${page}-sec-amount`]: 350,
  57. [`${page}-sec-products`]: 700,
  58. [`${page}-sec-${last}`]: 1300
  59. }
  60. const viewport = {
  61. scrollY: 0,
  62. innerHeight: 600,
  63. addEventListener: (name: string, fn: () => void) => listeners.set(name, fn),
  64. removeEventListener: (name: string, fn: () => void) => {
  65. assert.equal(listeners.get(name), fn)
  66. listeners.delete(name)
  67. },
  68. setTimeout: (callback: () => void, delay: number) => {
  69. timers.set(++timerId, { due: now + delay, callback })
  70. return timerId
  71. },
  72. clearTimeout: (id: number) => timers.delete(id)
  73. }
  74. const document = {
  75. scrollingElement: { scrollHeight: 1600 },
  76. querySelector: () => ({ getBoundingClientRect: () => ({ height: 56 }) }),
  77. getElementById: (id: string) =>
  78. id in tops
  79. ? {
  80. getBoundingClientRect: () => ({ top: tops[id] - viewport.scrollY }),
  81. scrollIntoView: (options: any) => scrolls.push({ id, options })
  82. }
  83. : null
  84. }
  85. const bindings = {
  86. props: { embedded: false },
  87. ref,
  88. computed,
  89. [page]: record,
  90. loading,
  91. route,
  92. window: viewport,
  93. document,
  94. Date: { now: () => now },
  95. onMounted: (fn: () => void) => mounts.push(fn),
  96. onUnmounted: (fn: () => void) => unmounts.push(fn)
  97. }
  98. const execute = new Function(
  99. 'state',
  100. ...Object.keys(bindings),
  101. `with (state) {
  102. ${ts.transpile(code, { target: ts.ScriptTarget.ESNext })}
  103. return { activeTab, onAnchorTab, sync: syncActiveTabFromScroll }
  104. }`
  105. )
  106. return {
  107. ...execute(state, ...Object.values(bindings)),
  108. state,
  109. record,
  110. loading,
  111. route,
  112. viewport,
  113. document,
  114. tops,
  115. scrolls,
  116. timers,
  117. last,
  118. mount: () => mounts.forEach((fn) => fn()),
  119. unmount: () => unmounts.forEach((fn) => fn()),
  120. scroll: () => listeners.get('scroll')!(),
  121. tick: (ms: number) => {
  122. now += ms
  123. for (const [id, timer] of [...timers]) {
  124. if (timer.due > now) continue
  125. timers.delete(id)
  126. timer.callback()
  127. }
  128. }
  129. }
  130. }
  131. for (const page of ['quote', 'contract'] as const) {
  132. test(`${page}: manual scroll and Home follow visible sections`, () => {
  133. const h = harness(page)
  134. h.viewport.scrollY = 650
  135. h.sync()
  136. assert.equal(h.activeTab.value, 'products')
  137. h.viewport.scrollY = 0
  138. h.sync()
  139. assert.equal(h.activeTab.value, 'base')
  140. })
  141. test(`${page}: bottom selects the visible short final section`, () => {
  142. const h = harness(page)
  143. h.viewport.scrollY = 1000
  144. h.sync()
  145. assert.equal(h.activeTab.value, h.last)
  146. })
  147. for (const edge of ['short', 'missing', 'below'])
  148. test(`${page}: ${edge} final section does not force bottom selection`, () => {
  149. const h = harness(page)
  150. if (edge === 'short') h.document.scrollingElement.scrollHeight = 500
  151. else {
  152. h.viewport.scrollY = 1000
  153. if (edge === 'missing') delete h.tops[`${page}-sec-${h.last}`]
  154. else h.tops[`${page}-sec-${h.last}`] = 1800
  155. }
  156. h.sync()
  157. assert.equal(h.activeTab.value, edge === 'short' ? 'base' : 'products')
  158. })
  159. test(`${page}: Home within click lock resyncs at 700ms without another scroll`, () => {
  160. const h = harness(page)
  161. h.mount()
  162. h.onAnchorTab({ name: h.last })
  163. assert.deepEqual(h.scrolls, [
  164. {
  165. id: `${page}-sec-${h.last}`,
  166. options: { behavior: 'smooth', block: 'start' }
  167. }
  168. ])
  169. h.viewport.scrollY = 1000
  170. h.tick(400)
  171. h.viewport.scrollY = 0
  172. h.scroll()
  173. h.tick(299)
  174. assert.equal(h.activeTab.value, h.last)
  175. h.tick(1)
  176. assert.equal(h.activeTab.value, 'base')
  177. assert.equal(h.timers.size, 0)
  178. })
  179. test(`${page}: consecutive clicks replace the lock timer`, () => {
  180. const h = harness(page)
  181. h.onAnchorTab({ name: h.last })
  182. h.tick(400)
  183. h.onAnchorTab({ name: 'products' })
  184. assert.equal(h.timers.size, 1)
  185. h.tick(300)
  186. assert.equal(h.activeTab.value, 'products')
  187. assert.equal(h.timers.size, 1)
  188. h.tick(400)
  189. assert.equal(h.activeTab.value, 'base')
  190. assert.equal(h.timers.size, 0)
  191. })
  192. test(`${page}: unmount cancels the pending timer`, () => {
  193. const h = harness(page)
  194. h.mount()
  195. h.onAnchorTab({ name: h.last })
  196. assert.equal(h.timers.size, 1)
  197. h.unmount()
  198. assert.equal(h.timers.size, 0)
  199. h.document.getElementById = () => assert.fail('unmounted timer reads DOM')
  200. h.tick(700)
  201. })
  202. for (const invalid of ['disposed', 'loading', 'notReady', 'mismatch', 'empty', 'missingId'])
  203. test(`${page}: ${invalid} record cannot navigate or read stale anchors`, () => {
  204. const h = harness(page)
  205. h.onAnchorTab({ name: h.last })
  206. if (invalid === 'disposed') h.state.disposed = true
  207. if (invalid === 'loading') h.loading.value = true
  208. if (invalid === 'notReady') h.state.shellReady = false
  209. if (invalid === 'mismatch') h.route.params.id = 'B'
  210. if (invalid === 'empty') h.record.value = null
  211. if (invalid === 'missingId') h.route.params.id = ''
  212. h.document.getElementById = () => assert.fail('invalid record reads stale DOM')
  213. h.tick(700)
  214. h.sync()
  215. h.onAnchorTab({ name: 'products' })
  216. assert.equal(h.activeTab.value, h.last)
  217. assert.equal(h.scrolls.length, 1)
  218. })
  219. test(`${page}: unknown or missing anchor leaves selection unchanged`, () => {
  220. const h = harness(page)
  221. h.onAnchorTab({ name: 'unknown' })
  222. delete h.tops[`${page}-sec-${h.last}`]
  223. h.onAnchorTab({ name: h.last })
  224. assert.equal(h.activeTab.value, 'base')
  225. assert.equal(h.scrolls.length, 0)
  226. })
  227. }