Files
shanxia2.0/web-admin/scripts/mobile-cert-detail-navigation.test.ts

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)
})