230 lines
7.5 KiB
TypeScript
230 lines
7.5 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 } from 'vue'
|
|
|
|
// Execute the production navigation declarations and lifecycle hooks; only browser
|
|
// geometry and time are controlled, so these tests do not mirror the scroll algorithm.
|
|
function harness(page: 'quote' | 'contract') {
|
|
const source = parse(
|
|
readFileSync(new URL(`../src/views/m/crm/${page}/detail.vue`, import.meta.url), 'utf8')
|
|
).descriptor.scriptSetup!.content
|
|
const ast = ts.createSourceFile('detail.ts', source, ts.ScriptTarget.Latest, true)
|
|
const names = new Set([
|
|
'activeTab',
|
|
'detailTabs',
|
|
'TAB_BAR_HEIGHT',
|
|
'stickyOffset',
|
|
'scrollMargin',
|
|
'scrollLockUntil',
|
|
'scrollLockTimer',
|
|
'currentRecordId',
|
|
'navigationReady',
|
|
'syncStickyOffset',
|
|
'syncActiveTabFromScroll',
|
|
'onDetailScroll',
|
|
'onAnchorTab'
|
|
])
|
|
const code = ast.statements
|
|
.filter((statement) => {
|
|
if (ts.isFunctionDeclaration(statement)) return names.has(statement.name!.text)
|
|
if (ts.isVariableStatement(statement))
|
|
return statement.declarationList.declarations.some((d) => names.has(d.name.getText(ast)))
|
|
return (
|
|
ts.isExpressionStatement(statement) &&
|
|
ts.isCallExpression(statement.expression) &&
|
|
['onMounted', 'onUnmounted'].includes(statement.expression.expression.getText(ast))
|
|
)
|
|
})
|
|
.map((statement) => statement.getText(ast))
|
|
.join('\n')
|
|
const mounts: Array<() => void> = [],
|
|
unmounts: Array<() => void> = []
|
|
const listeners = new Map<string, () => void>()
|
|
const timers = new Map<number, { due: number; callback: () => void }>()
|
|
const scrolls: any[] = []
|
|
let now = 1000,
|
|
timerId = 0
|
|
const record = ref<any>({ id: 'A' }),
|
|
loading = ref(false)
|
|
const route = { params: { id: 'A' } }
|
|
const state = { disposed: false, shellReady: true, detailRequestSeq: 0, categoryRequestSeq: 0 }
|
|
const last = page === 'quote' ? 'approval' : 'attachments'
|
|
const tops: Record<string, number> = {
|
|
[`${page}-sec-base`]: 100,
|
|
[`${page}-sec-amount`]: 350,
|
|
[`${page}-sec-products`]: 700,
|
|
[`${page}-sec-${last}`]: 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)
|
|
},
|
|
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: 56 }) }),
|
|
getElementById: (id: string) =>
|
|
id in tops
|
|
? {
|
|
getBoundingClientRect: () => ({ top: tops[id] - viewport.scrollY }),
|
|
scrollIntoView: (options: any) => scrolls.push({ id, options })
|
|
}
|
|
: null
|
|
}
|
|
const bindings = {
|
|
props: { embedded: false },
|
|
ref,
|
|
computed,
|
|
[page]: record,
|
|
loading,
|
|
route,
|
|
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, onAnchorTab, sync: syncActiveTabFromScroll }
|
|
}`
|
|
)
|
|
return {
|
|
...execute(state, ...Object.values(bindings)),
|
|
state,
|
|
record,
|
|
loading,
|
|
route,
|
|
viewport,
|
|
document,
|
|
tops,
|
|
scrolls,
|
|
timers,
|
|
last,
|
|
mount: () => mounts.forEach((fn) => fn()),
|
|
unmount: () => unmounts.forEach((fn) => fn()),
|
|
scroll: () => listeners.get('scroll')!(),
|
|
tick: (ms: number) => {
|
|
now += ms
|
|
for (const [id, timer] of [...timers]) {
|
|
if (timer.due > now) continue
|
|
timers.delete(id)
|
|
timer.callback()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const page of ['quote', 'contract'] as const) {
|
|
test(`${page}: manual scroll and Home follow visible sections`, () => {
|
|
const h = harness(page)
|
|
h.viewport.scrollY = 650
|
|
h.sync()
|
|
assert.equal(h.activeTab.value, 'products')
|
|
h.viewport.scrollY = 0
|
|
h.sync()
|
|
assert.equal(h.activeTab.value, 'base')
|
|
})
|
|
test(`${page}: bottom selects the visible short final section`, () => {
|
|
const h = harness(page)
|
|
h.viewport.scrollY = 1000
|
|
h.sync()
|
|
assert.equal(h.activeTab.value, h.last)
|
|
})
|
|
for (const edge of ['short', 'missing', 'below'])
|
|
test(`${page}: ${edge} final section does not force bottom selection`, () => {
|
|
const h = harness(page)
|
|
if (edge === 'short') h.document.scrollingElement.scrollHeight = 500
|
|
else {
|
|
h.viewport.scrollY = 1000
|
|
if (edge === 'missing') delete h.tops[`${page}-sec-${h.last}`]
|
|
else h.tops[`${page}-sec-${h.last}`] = 1800
|
|
}
|
|
h.sync()
|
|
assert.equal(h.activeTab.value, edge === 'short' ? 'base' : 'products')
|
|
})
|
|
test(`${page}: Home within click lock resyncs at 700ms without another scroll`, () => {
|
|
const h = harness(page)
|
|
h.mount()
|
|
h.onAnchorTab({ name: h.last })
|
|
assert.deepEqual(h.scrolls, [
|
|
{
|
|
id: `${page}-sec-${h.last}`,
|
|
options: { behavior: 'smooth', block: 'start' }
|
|
}
|
|
])
|
|
h.viewport.scrollY = 1000
|
|
h.tick(400)
|
|
h.viewport.scrollY = 0
|
|
h.scroll()
|
|
h.tick(299)
|
|
assert.equal(h.activeTab.value, h.last)
|
|
h.tick(1)
|
|
assert.equal(h.activeTab.value, 'base')
|
|
assert.equal(h.timers.size, 0)
|
|
})
|
|
test(`${page}: consecutive clicks replace the lock timer`, () => {
|
|
const h = harness(page)
|
|
h.onAnchorTab({ name: h.last })
|
|
h.tick(400)
|
|
h.onAnchorTab({ name: 'products' })
|
|
assert.equal(h.timers.size, 1)
|
|
h.tick(300)
|
|
assert.equal(h.activeTab.value, 'products')
|
|
assert.equal(h.timers.size, 1)
|
|
h.tick(400)
|
|
assert.equal(h.activeTab.value, 'base')
|
|
assert.equal(h.timers.size, 0)
|
|
})
|
|
test(`${page}: unmount cancels the pending timer`, () => {
|
|
const h = harness(page)
|
|
h.mount()
|
|
h.onAnchorTab({ name: h.last })
|
|
assert.equal(h.timers.size, 1)
|
|
h.unmount()
|
|
assert.equal(h.timers.size, 0)
|
|
h.document.getElementById = () => assert.fail('unmounted timer reads DOM')
|
|
h.tick(700)
|
|
})
|
|
for (const invalid of ['disposed', 'loading', 'notReady', 'mismatch', 'empty', 'missingId'])
|
|
test(`${page}: ${invalid} record cannot navigate or read stale anchors`, () => {
|
|
const h = harness(page)
|
|
h.onAnchorTab({ name: h.last })
|
|
if (invalid === 'disposed') h.state.disposed = true
|
|
if (invalid === 'loading') h.loading.value = true
|
|
if (invalid === 'notReady') h.state.shellReady = false
|
|
if (invalid === 'mismatch') h.route.params.id = 'B'
|
|
if (invalid === 'empty') h.record.value = null
|
|
if (invalid === 'missingId') h.route.params.id = ''
|
|
h.document.getElementById = () => assert.fail('invalid record reads stale DOM')
|
|
h.tick(700)
|
|
h.sync()
|
|
h.onAnchorTab({ name: 'products' })
|
|
assert.equal(h.activeTab.value, h.last)
|
|
assert.equal(h.scrolls.length, 1)
|
|
})
|
|
test(`${page}: unknown or missing anchor leaves selection unchanged`, () => {
|
|
const h = harness(page)
|
|
h.onAnchorTab({ name: 'unknown' })
|
|
delete h.tops[`${page}-sec-${h.last}`]
|
|
h.onAnchorTab({ name: h.last })
|
|
assert.equal(h.activeTab.value, 'base')
|
|
assert.equal(h.scrolls.length, 0)
|
|
})
|
|
}
|