| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288 |
- import assert from 'node:assert/strict'
- import test from 'node:test'
- import { readFileSync } from 'node:fs'
- import { parse } from '@vue/compiler-sfc'
- import ts from 'typescript'
- import { computed, ref, watch } from 'vue'
- const source = parse(
- readFileSync(new URL('../src/views/m/pl/plan-cert/CertDetail.vue', import.meta.url), 'utf8')
- ).descriptor.scriptSetup!.content
- // Execute production navigation declarations and lifecycle registrations. Only geometry,
- // time and animation scheduling are controlled: no browser or mirrored scroll algorithm.
- function harness() {
- const start = source.indexOf(' const detailTabs =')
- const code = source.slice(start, source.indexOf(' const showFeedback =', start))
- const mounts: Array<() => void> = [],
- unmounts: Array<() => void> = []
- const listeners = new Map<string, () => void>(),
- frames = new Map<number, () => void>()
- const timers = new Map<number, { due: number; callback: () => void }>()
- const scrolls: any[] = []
- let timerId = 0
- let frameId = 0,
- now = 1000,
- headerHeight = 66
- const state: Record<string, boolean> = { disposed: false, recordInvalidated: false }
- const plan = ref<any>({ id: 'A' }),
- loading = ref(false),
- props = { planId: 'A' }
- const tops: Record<string, number> = {
- 'cert-sec-base': 122,
- 'cert-sec-transport': 700,
- 'cert-sec-attachments': 1300
- }
- const viewport = {
- scrollY: 0,
- innerHeight: 600,
- addEventListener: (name: string, fn: () => void) => listeners.set(name, fn),
- removeEventListener: (name: string, fn: () => void) => {
- assert.equal(listeners.get(name), fn)
- listeners.delete(name)
- },
- requestAnimationFrame: (fn: () => void) => {
- frames.set(++frameId, fn)
- return frameId
- },
- cancelAnimationFrame: (id: number) => frames.delete(id),
- setTimeout: (callback: () => void, delay: number) => {
- timers.set(++timerId, { due: now + delay, callback })
- return timerId
- },
- clearTimeout: (id: number) => timers.delete(id)
- }
- const document = {
- scrollingElement: { scrollHeight: 1600 },
- querySelector: () => ({ getBoundingClientRect: () => ({ height: headerHeight }) }),
- getElementById: (id: string) =>
- id in tops
- ? {
- getBoundingClientRect: () => ({ top: tops[id] - viewport.scrollY }),
- scrollIntoView: (options: any) => scrolls.push({ id, options })
- }
- : null
- }
- const bindings = {
- ref,
- computed,
- watch,
- stops: ref({ deliveries: [] }),
- plan,
- props,
- loading,
- window: viewport,
- document,
- Date: { now: () => now },
- onMounted: (fn: () => void) => mounts.push(fn),
- onUnmounted: (fn: () => void) => unmounts.push(fn)
- }
- const execute = new Function(
- 'state',
- ...Object.keys(bindings),
- `with (state) {
- ${ts.transpile(code, { target: ts.ScriptTarget.ESNext })}
- return { activeTab, stickyOffset, onAnchorTab,
- sync: typeof syncActiveTabFromScroll === 'function' ? syncActiveTabFromScroll : () => {},
- margin: () => typeof scrollMargin !== 'undefined' ? scrollMargin.value : undefined }
- }`
- )
- const nav = execute(state, ...Object.values(bindings))
- return {
- ...nav,
- state,
- plan,
- props,
- loading,
- viewport,
- document,
- tops,
- scrolls,
- listeners,
- frames,
- timers,
- mount: () => mounts.forEach((fn) => fn()),
- unmount: () => unmounts.forEach((fn) => fn()),
- tick: (ms: number) => {
- now += ms
- for (const [id, timer] of [...timers]) {
- if (timer.due > now) continue
- timers.delete(id)
- timer.callback()
- }
- },
- resize: (height: number) => {
- headerHeight = height
- listeners.get('resize')?.()
- },
- flushFrame: () => {
- const pending = [...frames.values()]
- frames.clear()
- pending.forEach((fn) => fn())
- }
- }
- }
- test('manual scroll selects transport and Home restores base', () => {
- const h = harness()
- h.mount()
- h.viewport.scrollY = 650
- h.sync()
- assert.equal(h.activeTab.value, 'transport')
- h.viewport.scrollY = 0
- h.sync()
- assert.equal(h.activeTab.value, 'base')
- })
- test('bottom selects visible short final section', () => {
- const h = harness()
- h.mount()
- h.viewport.scrollY = 1000
- h.sync()
- assert.equal(h.activeTab.value, 'attachments')
- })
- for (const missing of [false, true])
- test(missing ? 'missing last section uses top probe' : 'short unscrolled page keeps base', () => {
- const h = harness()
- h.mount()
- if (missing) {
- delete h.tops['cert-sec-attachments']
- h.viewport.scrollY = 1000
- } else {
- h.document.scrollingElement.scrollHeight = 500
- h.tops['cert-sec-transport'] = 250
- h.tops['cert-sec-attachments'] = 400
- }
- h.sync()
- assert.equal(h.activeTab.value, missing ? 'transport' : 'base')
- })
- test('final section below viewport does not win bottom override', () => {
- const h = harness()
- h.mount()
- h.viewport.scrollY = 1000
- h.tops['cert-sec-attachments'] = 1800
- h.sync()
- assert.equal(h.activeTab.value, 'transport')
- })
- test('anchor click locks selected tab for 700ms and uses smooth positioning', () => {
- const h = harness()
- h.mount()
- h.onAnchorTab({ name: 'attachments' })
- assert.equal(h.activeTab.value, 'attachments')
- assert.deepEqual(h.scrolls, [
- { id: 'cert-sec-attachments', options: { behavior: 'smooth', block: 'start' } }
- ])
- h.tick(699)
- h.sync()
- assert.equal(h.activeTab.value, 'attachments')
- h.tick(1)
- h.sync()
- assert.equal(h.activeTab.value, 'base')
- })
- for (const invalid of ['disposed', 'recordInvalidated', 'loading', 'mismatch', 'empty'])
- test(`${invalid} record never reads stale anchors or starts scrolling`, () => {
- const h = harness()
- h.mount()
- if (invalid === 'loading') h.loading.value = true
- else if (invalid === 'mismatch') h.props.planId = 'B'
- else if (invalid === 'empty') h.plan.value = null
- else h.state[invalid] = true
- h.document.getElementById = () => {
- assert.fail('stale DOM must not be read')
- }
- h.viewport.scrollY = 1000
- h.sync()
- h.onAnchorTab({ name: 'attachments' })
- assert.equal(h.activeTab.value, 'base')
- assert.deepEqual(h.scrolls, [])
- })
- test('unknown or absent anchor cannot change active tab', () => {
- const h = harness()
- h.mount()
- h.onAnchorTab({ name: 'missing' })
- delete h.tops['cert-sec-attachments']
- h.onAnchorTab({ name: 'attachments' })
- assert.equal(h.activeTab.value, 'base')
- assert.deepEqual(h.scrolls, [])
- })
- test('mount measures header; resize recomputes header + nav + gap', () => {
- const h = harness()
- h.mount()
- assert.equal(h.stickyOffset.value, 66)
- assert.equal(h.margin(), 122)
- h.resize(78.3)
- assert.equal(h.stickyOffset.value, 79)
- assert.equal(h.margin(), 135)
- })
- test('scroll lifecycle coalesces frames and removes listeners and pending frame on unmount', () => {
- const h = harness()
- h.mount()
- assert.ok(h.listeners.has('scroll'))
- h.viewport.scrollY = 650
- h.listeners.get('scroll')!()
- h.listeners.get('scroll')!()
- assert.equal(h.frames.size, 1)
- h.flushFrame()
- assert.equal(h.activeTab.value, 'transport')
- h.listeners.get('scroll')!()
- h.unmount()
- assert.equal(h.frames.size, 0)
- assert.equal(h.listeners.size, 0)
- })
- test('Home during click lock resyncs once lock expires without another scroll event', () => {
- const h = harness()
- h.mount()
- h.onAnchorTab({ name: 'attachments' })
- h.viewport.scrollY = 1000
- h.tick(400)
- h.viewport.scrollY = 0
- h.listeners.get('scroll')!()
- h.flushFrame()
- assert.equal(h.activeTab.value, 'attachments')
- h.tick(300)
- assert.equal(h.activeTab.value, 'base')
- assert.equal(h.timers.size, 0)
- })
- test('consecutive anchor clicks replace the previous lock-expiry timer', () => {
- const h = harness()
- h.mount()
- h.onAnchorTab({ name: 'attachments' })
- h.tick(400)
- h.onAnchorTab({ name: 'transport' })
- assert.equal(h.timers.size, 1)
- h.tick(300)
- assert.equal(h.activeTab.value, 'transport')
- assert.equal(h.timers.size, 1)
- h.tick(400)
- assert.equal(h.activeTab.value, 'base')
- assert.equal(h.timers.size, 0)
- })
- test('unmount cancels pending lock-expiry timer', () => {
- const h = harness()
- h.mount()
- h.onAnchorTab({ name: 'attachments' })
- assert.equal(h.timers.size, 1)
- h.unmount()
- assert.equal(h.timers.size, 0)
- h.document.getElementById = () => {
- assert.fail('unmounted timer must not read DOM')
- }
- h.tick(700)
- assert.equal(h.activeTab.value, 'attachments')
- })
- test('lock expiry after record invalidation does not read stale sections', () => {
- const h = harness()
- h.mount()
- h.onAnchorTab({ name: 'attachments' })
- assert.equal(h.timers.size, 1)
- h.state.recordInvalidated = true
- h.document.getElementById = () => {
- assert.fail('invalidated timer must not read DOM')
- }
- h.tick(700)
- assert.equal(h.activeTab.value, 'attachments')
- assert.equal(h.timers.size, 0)
- })
|