mobile-cert-detail-navigation.test.ts 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286
  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. const source = parse(
  8. readFileSync(new URL('../src/views/m/pl/plan-cert/CertDetail.vue', import.meta.url), 'utf8')
  9. ).descriptor.scriptSetup!.content
  10. // Execute production navigation declarations and lifecycle registrations. Only geometry,
  11. // time and animation scheduling are controlled: no browser or mirrored scroll algorithm.
  12. function harness() {
  13. const start = source.indexOf(' const detailTabs =')
  14. const code = source.slice(start, source.indexOf(' const showFeedback =', start))
  15. const mounts: Array<() => void> = [],
  16. unmounts: Array<() => void> = []
  17. const listeners = new Map<string, () => void>(),
  18. frames = new Map<number, () => void>()
  19. const timers = new Map<number, { due: number; callback: () => void }>()
  20. const scrolls: any[] = []
  21. let timerId = 0
  22. let frameId = 0,
  23. now = 1000,
  24. headerHeight = 66
  25. const state: Record<string, boolean> = { disposed: false, recordInvalidated: false }
  26. const plan = ref<any>({ id: 'A' }),
  27. loading = ref(false),
  28. props = { planId: 'A' }
  29. const tops: Record<string, number> = {
  30. 'cert-sec-base': 122,
  31. 'cert-sec-transport': 700,
  32. 'cert-sec-attachments': 1300
  33. }
  34. const viewport = {
  35. scrollY: 0,
  36. innerHeight: 600,
  37. addEventListener: (name: string, fn: () => void) => listeners.set(name, fn),
  38. removeEventListener: (name: string, fn: () => void) => {
  39. assert.equal(listeners.get(name), fn)
  40. listeners.delete(name)
  41. },
  42. requestAnimationFrame: (fn: () => void) => {
  43. frames.set(++frameId, fn)
  44. return frameId
  45. },
  46. cancelAnimationFrame: (id: number) => frames.delete(id),
  47. setTimeout: (callback: () => void, delay: number) => {
  48. timers.set(++timerId, { due: now + delay, callback })
  49. return timerId
  50. },
  51. clearTimeout: (id: number) => timers.delete(id)
  52. }
  53. const document = {
  54. scrollingElement: { scrollHeight: 1600 },
  55. querySelector: () => ({ getBoundingClientRect: () => ({ height: headerHeight }) }),
  56. getElementById: (id: string) =>
  57. id in tops
  58. ? {
  59. getBoundingClientRect: () => ({ top: tops[id] - viewport.scrollY }),
  60. scrollIntoView: (options: any) => scrolls.push({ id, options })
  61. }
  62. : null
  63. }
  64. const bindings = {
  65. ref,
  66. computed,
  67. plan,
  68. props,
  69. loading,
  70. window: viewport,
  71. document,
  72. Date: { now: () => now },
  73. onMounted: (fn: () => void) => mounts.push(fn),
  74. onUnmounted: (fn: () => void) => unmounts.push(fn)
  75. }
  76. const execute = new Function(
  77. 'state',
  78. ...Object.keys(bindings),
  79. `with (state) {
  80. ${ts.transpile(code, { target: ts.ScriptTarget.ESNext })}
  81. return { activeTab, stickyOffset, onAnchorTab,
  82. sync: typeof syncActiveTabFromScroll === 'function' ? syncActiveTabFromScroll : () => {},
  83. margin: () => typeof scrollMargin !== 'undefined' ? scrollMargin.value : undefined }
  84. }`
  85. )
  86. const nav = execute(state, ...Object.values(bindings))
  87. return {
  88. ...nav,
  89. state,
  90. plan,
  91. props,
  92. loading,
  93. viewport,
  94. document,
  95. tops,
  96. scrolls,
  97. listeners,
  98. frames,
  99. timers,
  100. mount: () => mounts.forEach((fn) => fn()),
  101. unmount: () => unmounts.forEach((fn) => fn()),
  102. tick: (ms: number) => {
  103. now += ms
  104. for (const [id, timer] of [...timers]) {
  105. if (timer.due > now) continue
  106. timers.delete(id)
  107. timer.callback()
  108. }
  109. },
  110. resize: (height: number) => {
  111. headerHeight = height
  112. listeners.get('resize')?.()
  113. },
  114. flushFrame: () => {
  115. const pending = [...frames.values()]
  116. frames.clear()
  117. pending.forEach((fn) => fn())
  118. }
  119. }
  120. }
  121. test('manual scroll selects transport and Home restores base', () => {
  122. const h = harness()
  123. h.mount()
  124. h.viewport.scrollY = 650
  125. h.sync()
  126. assert.equal(h.activeTab.value, 'transport')
  127. h.viewport.scrollY = 0
  128. h.sync()
  129. assert.equal(h.activeTab.value, 'base')
  130. })
  131. test('bottom selects visible short final section', () => {
  132. const h = harness()
  133. h.mount()
  134. h.viewport.scrollY = 1000
  135. h.sync()
  136. assert.equal(h.activeTab.value, 'attachments')
  137. })
  138. for (const missing of [false, true])
  139. test(missing ? 'missing last section uses top probe' : 'short unscrolled page keeps base', () => {
  140. const h = harness()
  141. h.mount()
  142. if (missing) {
  143. delete h.tops['cert-sec-attachments']
  144. h.viewport.scrollY = 1000
  145. } else {
  146. h.document.scrollingElement.scrollHeight = 500
  147. h.tops['cert-sec-transport'] = 250
  148. h.tops['cert-sec-attachments'] = 400
  149. }
  150. h.sync()
  151. assert.equal(h.activeTab.value, missing ? 'transport' : 'base')
  152. })
  153. test('final section below viewport does not win bottom override', () => {
  154. const h = harness()
  155. h.mount()
  156. h.viewport.scrollY = 1000
  157. h.tops['cert-sec-attachments'] = 1800
  158. h.sync()
  159. assert.equal(h.activeTab.value, 'transport')
  160. })
  161. test('anchor click locks selected tab for 700ms and uses smooth positioning', () => {
  162. const h = harness()
  163. h.mount()
  164. h.onAnchorTab({ name: 'attachments' })
  165. assert.equal(h.activeTab.value, 'attachments')
  166. assert.deepEqual(h.scrolls, [
  167. { id: 'cert-sec-attachments', options: { behavior: 'smooth', block: 'start' } }
  168. ])
  169. h.tick(699)
  170. h.sync()
  171. assert.equal(h.activeTab.value, 'attachments')
  172. h.tick(1)
  173. h.sync()
  174. assert.equal(h.activeTab.value, 'base')
  175. })
  176. for (const invalid of ['disposed', 'recordInvalidated', 'loading', 'mismatch', 'empty'])
  177. test(`${invalid} record never reads stale anchors or starts scrolling`, () => {
  178. const h = harness()
  179. h.mount()
  180. if (invalid === 'loading') h.loading.value = true
  181. else if (invalid === 'mismatch') h.props.planId = 'B'
  182. else if (invalid === 'empty') h.plan.value = null
  183. else h.state[invalid] = true
  184. h.document.getElementById = () => {
  185. assert.fail('stale DOM must not be read')
  186. }
  187. h.viewport.scrollY = 1000
  188. h.sync()
  189. h.onAnchorTab({ name: 'attachments' })
  190. assert.equal(h.activeTab.value, 'base')
  191. assert.deepEqual(h.scrolls, [])
  192. })
  193. test('unknown or absent anchor cannot change active tab', () => {
  194. const h = harness()
  195. h.mount()
  196. h.onAnchorTab({ name: 'missing' })
  197. delete h.tops['cert-sec-attachments']
  198. h.onAnchorTab({ name: 'attachments' })
  199. assert.equal(h.activeTab.value, 'base')
  200. assert.deepEqual(h.scrolls, [])
  201. })
  202. test('mount measures header; resize recomputes header + nav + gap', () => {
  203. const h = harness()
  204. h.mount()
  205. assert.equal(h.stickyOffset.value, 66)
  206. assert.equal(h.margin(), 122)
  207. h.resize(78.3)
  208. assert.equal(h.stickyOffset.value, 79)
  209. assert.equal(h.margin(), 135)
  210. })
  211. test('scroll lifecycle coalesces frames and removes listeners and pending frame on unmount', () => {
  212. const h = harness()
  213. h.mount()
  214. assert.ok(h.listeners.has('scroll'))
  215. h.viewport.scrollY = 650
  216. h.listeners.get('scroll')!()
  217. h.listeners.get('scroll')!()
  218. assert.equal(h.frames.size, 1)
  219. h.flushFrame()
  220. assert.equal(h.activeTab.value, 'transport')
  221. h.listeners.get('scroll')!()
  222. h.unmount()
  223. assert.equal(h.frames.size, 0)
  224. assert.equal(h.listeners.size, 0)
  225. })
  226. test('Home during click lock resyncs once lock expires without another scroll event', () => {
  227. const h = harness()
  228. h.mount()
  229. h.onAnchorTab({ name: 'attachments' })
  230. h.viewport.scrollY = 1000
  231. h.tick(400)
  232. h.viewport.scrollY = 0
  233. h.listeners.get('scroll')!()
  234. h.flushFrame()
  235. assert.equal(h.activeTab.value, 'attachments')
  236. h.tick(300)
  237. assert.equal(h.activeTab.value, 'base')
  238. assert.equal(h.timers.size, 0)
  239. })
  240. test('consecutive anchor clicks replace the previous lock-expiry timer', () => {
  241. const h = harness()
  242. h.mount()
  243. h.onAnchorTab({ name: 'attachments' })
  244. h.tick(400)
  245. h.onAnchorTab({ name: 'transport' })
  246. assert.equal(h.timers.size, 1)
  247. h.tick(300)
  248. assert.equal(h.activeTab.value, 'transport')
  249. assert.equal(h.timers.size, 1)
  250. h.tick(400)
  251. assert.equal(h.activeTab.value, 'base')
  252. assert.equal(h.timers.size, 0)
  253. })
  254. test('unmount cancels pending lock-expiry timer', () => {
  255. const h = harness()
  256. h.mount()
  257. h.onAnchorTab({ name: 'attachments' })
  258. assert.equal(h.timers.size, 1)
  259. h.unmount()
  260. assert.equal(h.timers.size, 0)
  261. h.document.getElementById = () => {
  262. assert.fail('unmounted timer must not read DOM')
  263. }
  264. h.tick(700)
  265. assert.equal(h.activeTab.value, 'attachments')
  266. })
  267. test('lock expiry after record invalidation does not read stale sections', () => {
  268. const h = harness()
  269. h.mount()
  270. h.onAnchorTab({ name: 'attachments' })
  271. assert.equal(h.timers.size, 1)
  272. h.state.recordInvalidated = true
  273. h.document.getElementById = () => {
  274. assert.fail('invalidated timer must not read DOM')
  275. }
  276. h.tick(700)
  277. assert.equal(h.activeTab.value, 'attachments')
  278. assert.equal(h.timers.size, 0)
  279. })