535 lines
17 KiB
TypeScript
535 lines
17 KiB
TypeScript
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()
|
|
})
|