import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' import { compile, computed, createSSRApp, effectScope, proxyRefs, ref, watch } from 'vue' import { renderToString } from 'vue/server-renderer' import { parse } from '@vue/compiler-sfc' import ts from 'typescript' const file = new URL('../src/views/crm/quote/components/QuoteFormPanel.vue', import.meta.url) const { descriptor } = parse(readFileSync(file, 'utf8')) const ast = ts.createSourceFile( 'QuoteFormPanel.ts', descriptor.scriptSetup!.content, ts.ScriptTarget.Latest, true ) function deferred() { let resolve!: (value: any) => void let reject!: (error: Error) => void const promise = new Promise((yes, no) => { resolve = yes reject = no }) return { promise, resolve, reject } } // Run the component's actual mobile declarations with Vue reactivity and transport stubs. // No component mount, browser, HTTP request, or database is involved. function harness(page = true) { const names: string[] = [] const selected = ast.statements.filter((node) => { if (ts.isVariableStatement(node)) { const name = node.declarationList.declarations[0].name.getText(ast) if ( /^(mobile|filteredMobile|scheduleMobile)/.test(name) || ['approverPickerOpen', 'extractCustomerList', 'handleAddProduct'].includes(name) ) { names.push(name) return true } } if (ts.isFunctionDeclaration(node)) { const name = node.name?.text || '' if (/Mobile|^createMobile|^toggleApprover$/.test(name)) { names.push(name) return true } } return ( ts.isExpressionStatement(node) && /^(watch\(|onBeforeUnmount\(|useIntersectionObserver\()/.test(node.getText(ast)) && /[mM]obile/.test(node.getText(ast)) ) }) const timers = new Map void; at: number }>() let now = 0 let timerId = 0 const setTimer = (fn: () => void, delay = 0) => { timers.set(++timerId, { fn, at: now + delay }) return timerId } const clearTimer = (id: number) => timers.delete(id) const advance = async (ms: number) => { now += ms for (const [id, timer] of [...timers]) { if (timer.at > now) continue timers.delete(id) timer.fn() } for (let i = 0; i < 8; i++) await Promise.resolve() } const calls: Array<{ kind: string; query: any; request: ReturnType }> = [] const transport = (kind: string) => (query: any) => { const request = deferred() calls.push({ kind, query, request }) return request.promise } const cleanups: Array<() => void> = [] const observers: Array<() => void> = [] const confirmed: any[][] = [] const bindings = { ref, computed, watch, setTimeout: setTimer, clearTimeout: clearTimer, onBeforeUnmount: (fn: () => void) => cleanups.push(fn), useIntersectionObserver: (_target: any, fn: any) => observers.push(() => fn([{ isIntersecting: true }])), useDebounceFn: (fn: () => void, delay: number) => { let id: number return () => { clearTimer(id) id = setTimer(fn, delay) } }, isPageVariant: ref(page), route: { fullPath: '/m/crm/quote/new' }, contactOptionsRequest: 0, contactOptionsLoading: ref(false), contactOptionsError: ref(''), form: ref({ supplierId: 's1', customerId: 'c1', products: [] }), productSelectorVisible: ref(false), customerSelectorVisible: ref(false), opportunitySelectorVisible: ref(false), contactSelectorVisible: ref(false), contactOptions: ref([ { id: 'c1', contactName: '张三', mobile: '13812345678' }, { id: 'c2', contactName: '李四', mobile: '13987654321' } ]), submitForm: ref({ approverIds: ['u2', 'u1'] }), userList: ref([ { userId: 'u1', nickName: '张三' }, { userId: 'u2', nickName: '李四' } ]), formatApproverOptionLabel: (user: any) => user.nickName, fetchProductList: transport('product'), fetchCustomerList: transport('customer'), listOpportunity: transport('opportunity'), handleProductConfirm: (items: any[]) => confirmed.push(items), showFailToast: () => {}, $modal: { msgWarning: () => {} }, loadContactOptions: async () => {}, handleContactConfirm: () => {}, handleOpportunityConfirm: () => {}, hasCustomerShortName: () => true, handleCustomerConfirm: async () => {} } const code = ts.transpile(selected.map((node) => node.getText(ast)).join('\n'), { target: ts.ScriptTarget.ESNext }) const scope = effectScope() const api = scope.run(() => new Function(...Object.keys(bindings), `${code}\nreturn { ${names.join(', ')} }`)( ...Object.values(bindings) ) ) return { ...bindings, ...api, calls, confirmed, observers, advance, async flush() { for (let i = 0; i < 8; i++) await Promise.resolve() }, async debounce() { await advance(250) }, dispose() { cleanups.forEach((fn) => fn()) scope.stop() } } } const row = (id: string) => ({ id, productName: id, customerName: id, opportunityName: id }) const response = (ids: string[], total: number | string = ids.length) => ({ data: { list: ids.map(row), total } }) async function statusMarkup(kind: string, h: any) { let body: any const visit = (node: any) => { if ( node.tag === 'div' && node.children?.some( (child: any) => child.tag === 'van-search' && child.loc.source.includes(`mobile${kind}Keyword`) ) ) body = node node.children?.forEach(visit) } visit(descriptor.template!.ast) assert.ok(body) const fragment = body.children .filter( (node: any) => node.type === 1 && !['van-search', 'van-cell', 'van-checkbox-group'].includes(node.tag) && !node.props?.some((prop: any) => prop.type === 7 && prop.name === 'for') ) .map((node: any) => node.loc.source) .join('\n') const compiled = compile(fragment, { isCustomElement: () => true }) const bindings = proxyRefs(h) return renderToString(createSSRApp({ render: () => compiled.call(bindings, bindings, []) })) } test('product page two failure retries page two without skipping it', async () => { const h = harness() const open = h.handleAddProduct() h.calls[0].request.resolve(response(['p1'], 50)) await open const more = h.loadMoreMobileProducts() h.calls[1].request.reject(new Error('offline')) await more const retry = h.loadMoreMobileProducts() assert.equal(h.calls[2].query.page, 2) h.calls[2].request.resolve(response(['p2'], 50)) await retry assert.deepEqual( h.mobileProductList.value.map((item: any) => item.id), ['p1', 'p2'] ) h.dispose() }) test('typing invalidates the in-flight product request before the debounce fires', async () => { const h = harness() const open = h.handleAddProduct() h.mobileProductKeyword.value = '乙' h.onMobileProductKeywordInput?.() h.calls[0].request.resolve(response(['stale'])) await open assert.deepEqual(h.mobileProductList.value, []) await h.debounce() assert.equal(h.calls.at(-1).query.keyword, '乙') h.calls.at(-1).request.resolve(response(['new'])) await h.flush() assert.equal(h.mobileProductList.value[0].id, 'new') h.dispose() }) test('old product finally cannot clear loading after closing and reopening', async () => { const h = harness() const first = h.handleAddProduct() h.productSelectorVisible.value = false const second = h.handleAddProduct() h.calls[0].request.resolve(response(['stale'])) await first assert.equal(h.mobileProductLoading.value, true) assert.deepEqual(h.mobileProductList.value, []) h.calls[1].request.resolve(response(['fresh'])) await second h.productSelectorVisible.value = false const third = h.handleAddProduct() h.productSelectorVisible.value = false h.calls[2].request.resolve(response(['closed'])) await third assert.deepEqual(h.mobileProductList.value, []) h.dispose() }) test('cross-search product selection confirms cached objects in selection order', async () => { const h = harness() const open = h.handleAddProduct() h.calls[0].request.resolve(response(['A'])) await open h.toggleMobileProduct('A') h.mobileProductKeyword.value = 'B' const search = h.loadMobileProducts() h.calls.at(-1).request.resolve(response(['B'])) await search h.toggleMobileProduct('B') h.confirmMobileProductPick() assert.deepEqual( h.confirmed[0].map((item: any) => item.id), ['A', 'B'] ) h.dispose() }) test('supplier or customer changes prevent confirming products from the previous context', async () => { for (const key of ['supplierId', 'customerId']) { const h = harness() const open = h.handleAddProduct() h.calls[0].request.resolve(response(['A'])) await open h.toggleMobileProduct('A') h.form.value[key] = 'changed' h.confirmMobileProductPick() assert.equal(h.confirmed.length, 0) assert.equal(h.productSelectorVisible.value, false) h.dispose() } }) test('customer input and clear search automatically; old responses cannot overwrite newer results', async () => { const h = harness() const open = h.openMobileCustomer() h.calls[0].request.resolve(response(['initial'])) await open h.mobileCustomerKeyword.value = '池州' await h.debounce() assert.equal(h.calls.length, 2) h.mobileCustomerKeyword.value = '国臣' await h.debounce() h.calls[2].request.resolve(response(['current'])) await h.flush() h.calls[1].request.resolve(response(['old'])) await h.flush() assert.deepEqual( h.mobileCustomerList.value.map((item: any) => item.id), ['current'] ) h.mobileCustomerKeyword.value = '' await h.debounce() assert.equal(h.calls[3].query.keyword, undefined) h.calls[3].request.resolve(response(['cleared'])) await h.flush() h.dispose() }) test('customer and opportunity matches beyond thirty remain reachable', async () => { for (const kind of ['Customer', 'Opportunity']) { const h = harness() const open = h[`openMobile${kind}`]() const rows = Array.from({ length: 30 }, (_, i) => row(String(i))) h.calls[0].request.resolve( kind === 'Customer' ? { data: { list: rows, total: '31' } } : { rows, total: '31' } ) await open assert.equal( typeof h[`loadMoreMobile${kind === 'Customer' ? 'Customers' : 'Opportunities'}`], 'function' ) const more = h[`loadMoreMobile${kind === 'Customer' ? 'Customers' : 'Opportunities'}`]() assert.equal(h.calls[1].query.page ?? h.calls[1].query.pageNum, 2) h.calls[1].request.resolve( kind === 'Customer' ? response(['30'], 31) : { rows: [row('30')], total: 31 } ) await more assert.equal(h[`mobile${kind}List`].value.length, 31) h.dispose() } }) test('opportunity customer change discards the old response and queries the new customer', async () => { const h = harness() const open = h.openMobileOpportunity() h.form.value.customerId = 'c2' h.calls[0].request.resolve({ rows: [row('old')], total: 1 }) await open assert.deepEqual(h.mobileOpportunityList.value, []) await h.debounce() assert.equal(h.calls[1].query.customerId, 'c2') h.calls[1].request.resolve({ rows: [row('new')], total: 1 }) await h.flush() assert.equal(h.mobileOpportunityList.value[0].id, 'new') h.dispose() }) test('unmount invalidates unresolved requests and scheduled searches', async () => { const h = harness() const open = h.handleAddProduct() h.mobileProductKeyword.value = 'later' h.onMobileProductKeywordInput?.() h.dispose() h.calls[0].request.resolve(response(['unmounted'])) await open await h.debounce() assert.deepEqual(h.mobileProductList.value, []) assert.equal(h.calls.length, 1) }) test('mobile contact and approver filters search locally without changing selected order', () => { const h = harness() assert.ok(h.mobileContactKeyword) h.mobileContactKeyword.value = '8765' assert.deepEqual( h.filteredMobileContacts.value.map((item: any) => item.id), ['c2'] ) h.mobileContactKeyword.value = '张' assert.deepEqual( h.filteredMobileContacts.value.map((item: any) => item.id), ['c1'] ) h.mobileApproverKeyword.value = '张' assert.deepEqual( h.filteredMobileApprovers.value.map((item: any) => item.userId), ['u1'] ) assert.deepEqual(h.submitForm.value.approverIds, ['u2', 'u1']) h.dispose() }) test('first-page errors expose retry and never masquerade as an empty result', async () => { for (const kind of ['Product', 'Customer', 'Opportunity']) { const h = harness() const open = kind === 'Product' ? h.handleAddProduct() : h[`openMobile${kind}`]() assert.doesNotMatch(await statusMarkup(kind, h), /暂无/) h.calls[0].request.reject(new Error('offline')) await open const markup = await statusMarkup(kind, h) assert.match(markup, /加载失败/) assert.match(markup, /重试/) assert.doesNotMatch(markup, /暂无/) h.dispose() } }) test('load-more errors retain rows, expose a retry button, and stop automatic retries', async () => { const h = harness() const open = h.handleAddProduct() h.calls[0].request.resolve(response(['A'], 40)) await open const more = h.loadMoreMobileProducts() h.calls[1].request.reject(new Error('offline')) await more assert.deepEqual( h.mobileProductList.value.map((item: any) => item.id), ['A'] ) const markup = await statusMarkup('Product', h) assert.match(markup, /加载失败/) assert.match(markup, /重试加载更多/) h.observers[0]() assert.equal(h.calls.length, 2) h.dispose() }) test('the explicit search action cancels a pending debounce request', async () => { const h = harness() const open = h.openMobileCustomer() h.calls[0].request.resolve(response(['A'])) await open h.mobileCustomerKeyword.value = 'B' const search = h.loadMobileCustomers() h.calls[1].request.resolve(response(['B'])) await search await h.debounce() assert.equal(h.calls.length, 2) h.dispose() }) test('closing clears pending product choices without writing the form; reopening starts fresh', async () => { const h = harness() const open = h.handleAddProduct() h.calls[0].request.resolve(response(['A'])) await open h.onMobileProductSelectionChange(['A']) h.productSelectorVisible.value = false assert.deepEqual(h.form.value.products, []) assert.equal(h.confirmed.length, 0) const reopen = h.handleAddProduct() h.calls[1].request.resolve(response(['A'])) await reopen assert.deepEqual(h.mobileProductSelectedIds.value, []) h.dispose() }) test('desktop supplier changes and selector visibility never trigger mobile transport or cleanup', async () => { const h = harness(false) await h.handleAddProduct() assert.equal(h.productSelectorVisible.value, true) h.form.value.supplierId = 's2' h.form.value.customerId = 'c2' h.customerSelectorVisible.value = true h.opportunitySelectorVisible.value = true h.mobileCustomerKeyword.value = 'A' await h.debounce() assert.equal(h.productSelectorVisible.value, true) assert.equal(h.calls.length, 0) h.dispose() }) test('all remote pickers debounce typing for 250 ms and keep their original filters', async () => { for (const kind of ['Product', 'Customer', 'Opportunity']) { const h = harness() const open = kind === 'Product' ? h.handleAddProduct() : h[`openMobile${kind}`]() h.calls[0].request.resolve(response(['A'])) await open h[`mobile${kind}Keyword`].value = ' 查询 ' await h.advance(249) assert.equal(h.calls.length, 1) await h.advance(1) assert.equal(h.calls.length, 2) assert.equal(h.calls[1].query.keyword, '查询') if (kind === 'Product') { assert.deepEqual(h.calls[1].query, { page: 1, pageSize: 20, status: 'on', keyword: '查询', matchProductNo: false, matchModel: true, supplierId: 's1' }) } else if (kind === 'Opportunity') { assert.equal(h.calls[1].query.customerId, 'c1') } h.calls[1].request.resolve(response(['B'])) await h.flush() h.dispose() } }) test('customer and opportunity closing/reopening discard old success and error responses', async () => { for (const kind of ['Customer', 'Opportunity']) { const h = harness() const visible = h[`${kind.toLowerCase()}SelectorVisible`] const first = h[`openMobile${kind}`]() visible.value = false const second = h[`openMobile${kind}`]() h.calls[0].request.reject(new Error('stale failure')) await first assert.equal(h[`mobile${kind}Loading`].value, true) assert.equal(h[`mobile${kind}Error`].value, '') h.calls[1].request.resolve(response(['fresh'])) await second visible.value = false const third = h[`openMobile${kind}`]() visible.value = false h.calls[2].request.resolve(response(['closed'])) await third assert.deepEqual(h[`mobile${kind}List`].value, []) h.dispose() } }) test('product checkbox changes preserve cached selection across pages and deselection order', async () => { const h = harness() const open = h.handleAddProduct() h.calls[0].request.resolve(response(['A'], 40)) await open h.onMobileProductSelectionChange(['A']) const more = h.loadMoreMobileProducts() h.calls[1].request.resolve(response(['B'], 40)) await more h.onMobileProductSelectionChange(['A', 'B']) h.onMobileProductSelectionChange(['B']) h.onMobileProductSelectionChange(['B', 'A']) h.confirmMobileProductPick() assert.deepEqual( h.confirmed[0].map((item: any) => item.id), ['B', 'A'] ) h.dispose() })