289 lines
8.7 KiB
TypeScript
289 lines
8.7 KiB
TypeScript
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)
|
|
})
|