| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534 |
- 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<any>((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<number, { fn: () => 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<typeof deferred> }> = []
- 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()
- })
|