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

324 lines
10 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 = {
name: page === 'quote' ? 'MpCrmQuoteDetail' : 'MpCrmContractDetail',
get path() {
return `/m/crm/${page}/${this.params.id}`
},
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'] 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)
})
}
function contractTabsHarness(list: (query: any) => Promise<any>) {
const descriptor = parse(
readFileSync(new URL('../src/views/m/crm/contract/detail.vue', import.meta.url), 'utf8')
).descriptor
const tree = ts.createSourceFile(
'contract.ts',
descriptor.scriptSetup!.content,
ts.ScriptTarget.Latest,
true
)
const names = [
'activeTab',
'detailTabs',
'linkedPlans',
'linkedPlansTotal',
'linkedPlansLoading',
'linkedPlansError',
'linkedPlansRequestSeq',
'onDetailTabChange',
'loadLinkedPlans'
]
const declarations = tree.statements
.filter((node) =>
ts.isFunctionDeclaration(node)
? names.includes(node.name!.text)
: ts.isVariableStatement(node) &&
node.declarationList.declarations.some((d) => names.includes(d.name.getText(tree)))
)
.map((node) => node.getText(tree))
.join('\n')
const scrolls: any[] = []
const bindings = {
ref,
props: { embedded: false },
contract: ref({ id: 'A' }),
planApi: { list },
window: { scrollTo: (options: any) => scrolls.push(options) }
}
const state = new Function(
...Object.keys(bindings),
ts.transpile(declarations, { target: ts.ScriptTarget.ESNext }) + `;return {${names.join(',')}}`
)(...Object.values(bindings))
return { ...state, scrolls }
}
test('contract detail uses six business panes and resets scroll when switching panes', () => {
let requests = 0
const h = contractTabsHarness(async () => {
requests++
return { list: [] }
})
assert.equal(h.activeTab.value, 'contract')
assert.deepEqual(
h.detailTabs.map((tab: any) => tab.key),
['contract', 'plans', 'payment', 'invoice', 'approval', 'operation']
)
for (const key of ['payment', 'invoice', 'approval', 'operation', 'contract']) {
h.onDetailTabChange(key)
assert.equal(h.activeTab.value, key)
}
assert.equal(requests, 0)
assert.deepEqual(h.scrolls, Array(5).fill({ top: 0, behavior: 'auto' }))
})
test('contract plans pane loads lazily without duplicating pending or cached requests', async () => {
let resolve!: (response: any) => void
const requests: any[] = []
const h = contractTabsHarness((query) => {
requests.push(query)
return new Promise((r) => {
resolve = r
})
})
h.onDetailTabChange('plans')
assert.equal(h.linkedPlansLoading.value, true)
h.onDetailTabChange('contract')
h.onDetailTabChange('plans')
assert.deepEqual(requests, [{ contractId: 'A', page: 1, pageSize: 50 }])
resolve({ data: { list: [{ id: 'P1' }], total: 1 } })
await Promise.resolve()
await Promise.resolve()
assert.equal(h.linkedPlansLoading.value, false)
assert.equal(h.linkedPlans.value[0].id, 'P1')
h.onDetailTabChange('contract')
h.onDetailTabChange('plans')
assert.equal(requests.length, 1)
})