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 void>() const timers = new Map void }>() const scrolls: any[] = [] let now = 1000, timerId = 0 const record = ref({ 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 = { [`${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) { 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) })