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