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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288
  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, watch } 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. watch,
  68. stops: ref({ deliveries: [] }),
  69. plan,
  70. props,
  71. loading,
  72. window: viewport,
  73. document,
  74. Date: { now: () => now },
  75. onMounted: (fn: () => void) => mounts.push(fn),
  76. onUnmounted: (fn: () => void) => unmounts.push(fn)
  77. }
  78. const execute = new Function(
  79. 'state',
  80. ...Object.keys(bindings),
  81. `with (state) {
  82. ${ts.transpile(code, { target: ts.ScriptTarget.ESNext })}
  83. return { activeTab, stickyOffset, onAnchorTab,
  84. sync: typeof syncActiveTabFromScroll === 'function' ? syncActiveTabFromScroll : () => {},
  85. margin: () => typeof scrollMargin !== 'undefined' ? scrollMargin.value : undefined }
  86. }`
  87. )
  88. const nav = execute(state, ...Object.values(bindings))
  89. return {
  90. ...nav,
  91. state,
  92. plan,
  93. props,
  94. loading,
  95. viewport,
  96. document,
  97. tops,
  98. scrolls,
  99. listeners,
  100. frames,
  101. timers,
  102. mount: () => mounts.forEach((fn) => fn()),
  103. unmount: () => unmounts.forEach((fn) => fn()),
  104. tick: (ms: number) => {
  105. now += ms
  106. for (const [id, timer] of [...timers]) {
  107. if (timer.due > now) continue
  108. timers.delete(id)
  109. timer.callback()
  110. }
  111. },
  112. resize: (height: number) => {
  113. headerHeight = height
  114. listeners.get('resize')?.()
  115. },
  116. flushFrame: () => {
  117. const pending = [...frames.values()]
  118. frames.clear()
  119. pending.forEach((fn) => fn())
  120. }
  121. }
  122. }
  123. test('manual scroll selects transport and Home restores base', () => {
  124. const h = harness()
  125. h.mount()
  126. h.viewport.scrollY = 650
  127. h.sync()
  128. assert.equal(h.activeTab.value, 'transport')
  129. h.viewport.scrollY = 0
  130. h.sync()
  131. assert.equal(h.activeTab.value, 'base')
  132. })
  133. test('bottom selects visible short final section', () => {
  134. const h = harness()
  135. h.mount()
  136. h.viewport.scrollY = 1000
  137. h.sync()
  138. assert.equal(h.activeTab.value, 'attachments')
  139. })
  140. for (const missing of [false, true])
  141. test(missing ? 'missing last section uses top probe' : 'short unscrolled page keeps base', () => {
  142. const h = harness()
  143. h.mount()
  144. if (missing) {
  145. delete h.tops['cert-sec-attachments']
  146. h.viewport.scrollY = 1000
  147. } else {
  148. h.document.scrollingElement.scrollHeight = 500
  149. h.tops['cert-sec-transport'] = 250
  150. h.tops['cert-sec-attachments'] = 400
  151. }
  152. h.sync()
  153. assert.equal(h.activeTab.value, missing ? 'transport' : 'base')
  154. })
  155. test('final section below viewport does not win bottom override', () => {
  156. const h = harness()
  157. h.mount()
  158. h.viewport.scrollY = 1000
  159. h.tops['cert-sec-attachments'] = 1800
  160. h.sync()
  161. assert.equal(h.activeTab.value, 'transport')
  162. })
  163. test('anchor click locks selected tab for 700ms and uses smooth positioning', () => {
  164. const h = harness()
  165. h.mount()
  166. h.onAnchorTab({ name: 'attachments' })
  167. assert.equal(h.activeTab.value, 'attachments')
  168. assert.deepEqual(h.scrolls, [
  169. { id: 'cert-sec-attachments', options: { behavior: 'smooth', block: 'start' } }
  170. ])
  171. h.tick(699)
  172. h.sync()
  173. assert.equal(h.activeTab.value, 'attachments')
  174. h.tick(1)
  175. h.sync()
  176. assert.equal(h.activeTab.value, 'base')
  177. })
  178. for (const invalid of ['disposed', 'recordInvalidated', 'loading', 'mismatch', 'empty'])
  179. test(`${invalid} record never reads stale anchors or starts scrolling`, () => {
  180. const h = harness()
  181. h.mount()
  182. if (invalid === 'loading') h.loading.value = true
  183. else if (invalid === 'mismatch') h.props.planId = 'B'
  184. else if (invalid === 'empty') h.plan.value = null
  185. else h.state[invalid] = true
  186. h.document.getElementById = () => {
  187. assert.fail('stale DOM must not be read')
  188. }
  189. h.viewport.scrollY = 1000
  190. h.sync()
  191. h.onAnchorTab({ name: 'attachments' })
  192. assert.equal(h.activeTab.value, 'base')
  193. assert.deepEqual(h.scrolls, [])
  194. })
  195. test('unknown or absent anchor cannot change active tab', () => {
  196. const h = harness()
  197. h.mount()
  198. h.onAnchorTab({ name: 'missing' })
  199. delete h.tops['cert-sec-attachments']
  200. h.onAnchorTab({ name: 'attachments' })
  201. assert.equal(h.activeTab.value, 'base')
  202. assert.deepEqual(h.scrolls, [])
  203. })
  204. test('mount measures header; resize recomputes header + nav + gap', () => {
  205. const h = harness()
  206. h.mount()
  207. assert.equal(h.stickyOffset.value, 66)
  208. assert.equal(h.margin(), 122)
  209. h.resize(78.3)
  210. assert.equal(h.stickyOffset.value, 79)
  211. assert.equal(h.margin(), 135)
  212. })
  213. test('scroll lifecycle coalesces frames and removes listeners and pending frame on unmount', () => {
  214. const h = harness()
  215. h.mount()
  216. assert.ok(h.listeners.has('scroll'))
  217. h.viewport.scrollY = 650
  218. h.listeners.get('scroll')!()
  219. h.listeners.get('scroll')!()
  220. assert.equal(h.frames.size, 1)
  221. h.flushFrame()
  222. assert.equal(h.activeTab.value, 'transport')
  223. h.listeners.get('scroll')!()
  224. h.unmount()
  225. assert.equal(h.frames.size, 0)
  226. assert.equal(h.listeners.size, 0)
  227. })
  228. test('Home during click lock resyncs once lock expires without another scroll event', () => {
  229. const h = harness()
  230. h.mount()
  231. h.onAnchorTab({ name: 'attachments' })
  232. h.viewport.scrollY = 1000
  233. h.tick(400)
  234. h.viewport.scrollY = 0
  235. h.listeners.get('scroll')!()
  236. h.flushFrame()
  237. assert.equal(h.activeTab.value, 'attachments')
  238. h.tick(300)
  239. assert.equal(h.activeTab.value, 'base')
  240. assert.equal(h.timers.size, 0)
  241. })
  242. test('consecutive anchor clicks replace the previous lock-expiry timer', () => {
  243. const h = harness()
  244. h.mount()
  245. h.onAnchorTab({ name: 'attachments' })
  246. h.tick(400)
  247. h.onAnchorTab({ name: 'transport' })
  248. assert.equal(h.timers.size, 1)
  249. h.tick(300)
  250. assert.equal(h.activeTab.value, 'transport')
  251. assert.equal(h.timers.size, 1)
  252. h.tick(400)
  253. assert.equal(h.activeTab.value, 'base')
  254. assert.equal(h.timers.size, 0)
  255. })
  256. test('unmount cancels pending lock-expiry timer', () => {
  257. const h = harness()
  258. h.mount()
  259. h.onAnchorTab({ name: 'attachments' })
  260. assert.equal(h.timers.size, 1)
  261. h.unmount()
  262. assert.equal(h.timers.size, 0)
  263. h.document.getElementById = () => {
  264. assert.fail('unmounted timer must not read DOM')
  265. }
  266. h.tick(700)
  267. assert.equal(h.activeTab.value, 'attachments')
  268. })
  269. test('lock expiry after record invalidation does not read stale sections', () => {
  270. const h = harness()
  271. h.mount()
  272. h.onAnchorTab({ name: 'attachments' })
  273. assert.equal(h.timers.size, 1)
  274. h.state.recordInvalidated = true
  275. h.document.getElementById = () => {
  276. assert.fail('invalidated timer must not read DOM')
  277. }
  278. h.tick(700)
  279. assert.equal(h.activeTab.value, 'attachments')
  280. assert.equal(h.timers.size, 0)
  281. })