Files
shanxia2.0/web-admin/scripts/mobile-crm-category-sort.test.ts
T

483 lines
17 KiB
TypeScript

import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { compile, computed, createSSRApp, h, nextTick, ref, watch } from 'vue'
import { renderToString } from 'vue/server-renderer'
import { parse } from '@vue/compiler-sfc'
import ts from 'typescript'
import { buildCustomerColumnFilterDefs } from '../src/views/crm/customer/columnFilterDefs'
import { stripSelfColumnParams } from '../src/composables/columnFacetContext'
import { buildPathLabelMapFromTreeRoots } from '../src/utils/tree-path'
const filter = 'views/m/crm/customer/CustomerFilters.vue'
const product = 'components/business/product-form-dialog/index.vue'
const cards = [
'views/m/crm/quote/detail.vue',
'views/m/crm/contract/detail.vue'
]
function source(file: string) {
const descriptor = parse(
readFileSync(new URL('../src/' + file, import.meta.url), 'utf8')
).descriptor
return {
descriptor,
ast: ts.createSourceFile(file, descriptor.scriptSetup!.content, ts.ScriptTarget.Latest, true)
}
}
function functions(
file: string,
names: string[],
bindings: Record<string, any>,
prelude = ''
): any {
const { ast } = source(file)
// Contract's existing loader delegates its identity guard. Execute the real dependencies;
// omitting them throws ReferenceError inside the loader's intentionally silent catch.
const methodNames = ['views/m/crm/contract/detail.vue', 'views/m/crm/quote/detail.vue'].includes(file) && names.includes('loadCategoryPaths')
? [...names, 'isCurrentLoad', 'currentRecordId']
: names
const runtimeBindings = methodNames !== names
? { disposed: false, props: { embedded: false }, ...bindings }
: bindings
const code = ts.transpile(
ast.statements
.filter((n) => ts.isFunctionDeclaration(n) && methodNames.includes(n.name?.text || ''))
.map((n) => n.getText(ast))
.join('\n'),
{ target: ts.ScriptTarget.ESNext }
)
return new Function(
...Object.keys(runtimeBindings),
`${prelude}\n${code}\nreturn { ${names.map((n) => `${n}: typeof ${n} === 'undefined' ? undefined : ${n}`).join(',')} }`
)(...Object.values(runtimeBindings))
}
function value(file: string, name: string, bindings: Record<string, any>) {
const { ast } = source(file)
const declaration = ast.statements
.filter(ts.isVariableStatement)
.flatMap((n) => n.declarationList.declarations)
.find((n) => n.name.getText(ast) === name)
assert.ok(declaration, `Missing ${name}`)
return new Function(
...Object.keys(bindings),
ts.transpile(`return ${declaration.initializer!.getText(ast)}`, {
target: ts.ScriptTarget.ESNext
})
)(...Object.values(bindings))
}
async function render(template: string, bindings: Record<string, any>, components = {}) {
const compiled = compile(template)
return renderToString(
createSSRApp({ components, render: () => compiled.call(bindings, bindings, []) })
)
}
function filterState(query = {}, context = {}) {
const defs = ref(buildCustomerColumnFilterDefs(() => [])),
emitted: any[] = [],
requests: any[] = [],
ticks: Array<() => void> = []
const b: any = {
defs,
datetimeDefs: computed(() => defs.value.filter((d) => d.type === 'datetime')),
props: { query, context, modelValue: true },
draft: ref({}),
contextDraft: ref({}),
error: ref(''),
nextTick: (f: () => void) => ticks.push(f),
singleKind: ref('view'),
numberOpProp: ref(''),
singleOpen: ref(false),
apiTime: (s: string) => s.replace('T', ' '),
emit: (...args: any[]) => emitted.push(args),
clearFacets: () => {},
facetKeywords: ref({}),
facetVersions: {},
facetContextVersion: 0,
facetLoading: ref({}),
enumOpen: ref(false),
enumProp: ref(''),
enumRemote: ref(false),
facets: ref({}),
enumOptions: ref([]),
stripSelfColumnParams,
unwrapRows: (x: any) => x,
fetchCustomerColumnFacetValues: async (p: any) => {
requests.push(p)
return []
}
}
const methods = functions(
filter,
[
'syncFromProps',
'resetDraft',
'buildQuery',
'openSingle',
'onSingleConfirm',
'resetAndApply',
'searchFacet',
'close'
],
b,
'let hydrating = false'
)
return { b, ...methods, emitted, requests, ticks }
}
const fields = [
'lastTime',
'nextTime',
'dealTime',
'intoPoolTime',
'claimTime',
'protectTime',
'createTime',
'updateTime'
]
for (const sortField of fields)
for (const sortOrder of ['asc', 'desc'])
test(`customer drops obsolete sort ${sortField}/${sortOrder}`, () => {
const s = filterState({ sortField, sortOrder, mobile: ' 123 ' })
s.syncFromProps()
assert.deepEqual(s.buildQuery(), { mobile: '123' })
assert.deepEqual(s.emitted, [])
s.ticks.forEach((f: () => void) => f())
assert.deepEqual(s.emitted, [])
})
test('remaining view and number pickers still apply their selected values', () => {
const s = filterState()
s.syncFromProps()
s.openSingle('view')
s.onSingleConfirm({ selectedValues: ['mine'] })
assert.equal(s.b.contextDraft.value.viewMode, 'mine')
s.openSingle('numberOp', 'dealAmount')
s.onSingleConfirm({ selectedValues: ['gte'] })
assert.equal(s.b.draft.value.dealAmountOp, 'gte')
assert.equal(s.b.singleOpen.value, false)
})
test('picker cancel and close do not restore obsolete sort; reset clears; facet strips pagination', async () => {
const s = filterState({ sortField: 'createTime', sortOrder: 'desc' }, { page: 3, pageSize: 20 })
s.syncFromProps()
s.openSingle('view')
s.b.singleOpen.value = false
s.close()
assert.equal(s.buildQuery().sortField, undefined)
await s.searchFacet('customerName', '')
for (const key of ['sortField', 'sortOrder', 'page', 'pageSize'])
assert.equal(s.requests[0][key], undefined)
assert.equal(s.buildQuery().sortField, undefined)
s.resetAndApply()
assert.deepEqual(s.buildQuery(), {})
assert.deepEqual(s.emitted.at(-2), ['apply', { query: {}, keyword: '', viewMode: 'all' }])
assert.match(source(filter).descriptor.template!.content, /@cancel="singleOpen = false"/)
})
test('customer filter UI omits sort controls and keeps the view picker', () => {
const template = source(filter).descriptor.template!.content
assert.doesNotMatch(template, /排序字段|排序方向|openSingle\('sort/)
assert.match(template, /openSingle\('view'\)/)
})
const tree = [
{
id: 1,
categoryName: '化工',
children: [{ id: 2, categoryName: '无机', children: [{ id: 3, categoryName: '同名' }] }]
},
{ id: '4', categoryName: '其他', children: [{ id: 5, categoryName: '同名' }] }
]
const pathMap = buildPathLabelMapFromTreeRoots(tree, {
nameKey: 'categoryName',
idKey: 'id',
childrenKey: 'children'
})
for (const file of cards) {
test(`${file} renders paths without changing snapshots`, async () => {
const categoryPaths = ref(pathMap),
{ displayCategory } = functions(file, ['displayCategory'], { categoryPaths })
assert.equal(typeof displayCategory, 'function')
const template = source(file).descriptor.template!.content
const match =
template.match(/<van-field[^>]*label="产品分类"[^>]*\/>/) ||
template.match(
/<div class="mp-wv-kv(?: [^"]*)?"><span>产品分类<\/span><span>[^]*?<\/span><\/div>/
)
assert.ok(match)
for (const [categoryId, categoryName, expected] of [
[3, '旧名', '化工 / 无机 / 同名'],
['5', '同名', '其他 / 同名'],
['unknown', '快照', '快照'],
['', '', '—']
]) {
const row = { categoryId, categoryName }
const html = await render(
match[0],
{ row, p: row, displayCategory },
{ VanField: { props: ['modelValue'], setup: (p: any) => () => h('span', p.modelValue) } }
)
assert.ok(html.includes(expected as string), html)
assert.deepEqual(row, { categoryId, categoryName })
}
categoryPaths.value = new Map()
assert.equal(displayCategory({ categoryId: 3, categoryName: '快照' }), '快照')
})
test(`${file} tree failure is harmless and dialog never fetches`, async () => {
let calls = 0
const categoryPaths = ref(new Map())
const b = {
categoryPaths,
isPageVariant: ref(file !== cards[0]),
categoryRequestSeq: 1,
detailRequestSeq: 1,
route: { name: file.includes('/contract/') ? 'MpCrmContractDetail' : 'MpCrmQuoteDetail', path: `/m/crm/${file.includes('/contract/') ? 'contract' : 'quote'}/q`, params: { id: 'q' } },
fetchCategoryTree: async () => {
calls++
throw Error('offline')
},
buildPathLabelMapFromTreeRoots
}
const { loadCategoryPaths } = functions(file, ['loadCategoryPaths'], b)
assert.equal(typeof loadCategoryPaths, 'function')
await loadCategoryPaths(1, 'q')
assert.equal(calls, file === cards[0] ? 0 : 1)
b.isPageVariant.value = true
await loadCategoryPaths(1, 'q')
assert.equal(categoryPaths.value.size, 0)
})
}
test('selected category path is page-only; dropdown name and numeric ID preserved', async () => {
const fragment = source(product).descriptor.template!.content.match(
/<el-tree-select[^]*?(?:\/>|<\/el-tree-select>)/
)![0]
const categoryOptions = ref(tree),
categoryPaths = value(product, 'categoryPaths', {
categoryOptions,
computed,
buildPathLabelMapFromTreeRoots
}),
formData = { categoryId: 3 }
const ElTreeSelect = {
props: ['modelValue', 'data'],
setup:
(p: any, { slots }: any) =>
() =>
h('section', [
h('div', slots.label?.({ label: '同名', value: p.modelValue }) || '同名'),
h('aside', p.data[0].categoryName)
])
}
const page = await render(
fragment,
{
formData,
categoryOptions: tree,
categoryPaths: categoryPaths.value,
categorySnapshot: null,
isPageVariant: true
},
{ ElTreeSelect }
)
const dialog = await render(
fragment,
{ formData, categoryOptions: tree, categoryPaths: categoryPaths.value, isPageVariant: false },
{ ElTreeSelect }
)
assert.match(page, /化工 \/ 无机 \/ 同名/)
assert.doesNotMatch(dialog, /化工 \/ 无机/)
assert.match(page, /<aside>化工<\/aside>/)
assert.equal(formData.categoryId, 3)
})
for (const file of cards) {
test(`${file} loads CRM tree and ignores stale detail responses`, async () => {
const categoryPaths = ref(new Map())
const b = {
categoryPaths,
isPageVariant: ref(true),
categoryRequestSeq: 2,
detailRequestSeq: 2,
route: { name: file.includes('/contract/') ? 'MpCrmContractDetail' : 'MpCrmQuoteDetail', path: `/m/crm/${file.includes('/contract/') ? 'contract' : 'quote'}/current`, params: { id: 'current' } },
fetchCategoryTree: async () => ({ data: tree }),
buildPathLabelMapFromTreeRoots
}
const { loadCategoryPaths } = functions(file, ['loadCategoryPaths'], b)
if (file !== cards[0]) {
await loadCategoryPaths(1, 'current')
assert.equal(categoryPaths.value.size, 0)
await loadCategoryPaths(2, 'old')
assert.equal(categoryPaths.value.size, 0)
}
await loadCategoryPaths(2, 'current')
assert.equal(categoryPaths.value.get('3'), '化工 / 无机 / 同名')
assert.deepEqual(tree[0].children[0].categoryName, '无机')
const imports = source(file)
.ast.statements.filter(ts.isImportDeclaration)
.map((n) => n.getText())
assert.ok(
imports.some((n) => n.includes('fetchCategoryTree') && n.includes('@/api/crm/product'))
)
})
}
test('actual hydration watcher emits no empty query; subsequent edit still uses 300ms live apply', async () => {
const s = filterState({ sortField: 'createTime', sortOrder: 'asc', mobile: '123' })
const { ast } = source(filter)
const names = ['syncFromProps', 'resetDraft', 'normalizeSortPair', 'buildQuery']
const declarations = ast.statements.filter(
(n) => ts.isFunctionDeclaration(n) && names.includes(n.name?.text || '')
)
const live = ast.statements.find(
(n) =>
ts.isVariableStatement(n) &&
n.declarationList.declarations.some((d) => d.name.getText(ast) === 'scheduleLiveApply')
)!
const watcher = ast.statements.find(
(n) =>
ts.isExpressionStatement(n) &&
ts.isCallExpression(n.expression) &&
n.expression.expression.getText(ast) === 'watch' &&
n.expression.arguments[0].getText(ast).includes('[draft, contextDraft]')
)!
let pending: () => void | undefined
const bindings = {
...s.b,
rangeError: value(filter, 'rangeError', { ...s.b, computed }),
nextTick,
watch: (...args: any[]) => {
return (watch as any)(...args)
},
useDebounceFn: (fn: () => void, delay: number) => {
assert.equal(delay, 300)
return () => {
pending = fn
}
}
}
const code = ts.transpile(
[
'let hydrating = false',
...declarations.map((n) => n.getText(ast)),
live.getText(ast),
'const stop = ' + watcher.getText(ast),
'return { syncFromProps, stop }'
].join('\n'),
{ target: ts.ScriptTarget.ESNext }
)
const methods = new Function(...Object.keys(bindings), code)(...Object.values(bindings))
methods.syncFromProps()
await nextTick()
assert.equal(pending, undefined)
assert.deepEqual(s.emitted, [])
s.b.draft.value.mobile = '456'
await nextTick()
assert.ok(pending)
pending!()
assert.deepEqual(s.emitted.at(-1), [
'apply',
{
query: { mobile: '456' },
keyword: '',
viewMode: 'all'
}
])
methods.stop()
})
test('page product edit keeps matching category snapshot when tree fails; no stale names or payload additions', async () => {
const categoryOptions = ref<any[]>([])
const categoryPaths = value(product, 'categoryPaths', {
categoryOptions,
computed,
buildPathLabelMapFromTreeRoots
})
const categorySnapshot = ref<{ id: string; name: string } | null>(null)
const props = { productId: 'product-A' }
const formData: any = {}
let response: any = {
id: 'product-A',
productName: '产品',
categoryId: 'cat3',
categoryName: '原叶名'
}
const payloads: any[] = []
const b = {
props,
formData,
categoryOptions,
categoryPaths,
categorySnapshot,
isPageVariant: ref(true),
dialogLoading: ref(false),
fetchCategoryTree: async () => {
throw Error('tree unavailable')
},
fetchProductDetail: async () => ({ data: response }),
ElMessage: { error: () => assert.fail('detail must not fail'), success: () => {} },
emit: () => {},
visible: ref(true),
formRef: ref({ validate: async () => true }),
productImagesUploadRef: ref(null),
submitLoading: ref(false),
isEdit: ref(true),
fetchUpdateProduct: async (p: any) => {
payloads.push(p)
return { data: p }
}
}
const methods = functions(
product,
['handleOpen', 'loadCategoryOptions', 'resetFormData', 'submitForm'],
b
)
const fragment = source(product).descriptor.template!.content.match(
/<el-tree-select[^]*?(?:\/>|<\/el-tree-select>)/
)![0]
const ElTreeSelect = {
props: ['modelValue'],
setup:
(p: any, { slots }: any) =>
() =>
h(
'span',
slots.label?.({ value: p.modelValue, label: p.modelValue }) || String(p.modelValue)
)
}
const label = () =>
render(
fragment,
{
...b,
categoryOptions: categoryOptions.value,
categoryPaths: categoryPaths.value,
categorySnapshot: categorySnapshot.value,
isPageVariant: b.isPageVariant.value
},
{ ElTreeSelect }
)
await methods.handleOpen()
assert.deepEqual(categoryOptions.value, [])
assert.match(await label(), /原叶名/)
assert.equal(formData.categoryId, 'cat3')
assert.equal(formData.categoryName, undefined)
await methods.submitForm()
assert.equal(payloads[0].categoryId, 'cat3')
assert.equal(payloads[0].categoryName, undefined)
assert.equal(payloads[0].categorySnapshot, undefined)
formData.categoryId = 'cat4'
assert.doesNotMatch(await label(), /原叶名/)
formData.categoryId = ''
assert.doesNotMatch(await label(), /原叶名/)
methods.resetFormData()
formData.categoryId = 'cat3'
assert.doesNotMatch(await label(), /原叶名/)
props.productId = 'product-B'
response = { id: 'product-B', categoryId: 'cat3', categoryName: '新会话名称' }
await methods.handleOpen()
assert.match(await label(), /新会话名称/)
assert.doesNotMatch(await label(), /原叶名/)
props.productId = ''
await methods.handleOpen()
formData.categoryId = 'cat3'
assert.doesNotMatch(await label(), /新会话名称/)
b.isPageVariant.value = false
props.productId = 'product-B'
await methods.handleOpen()
assert.match(await label(), /cat3/)
assert.doesNotMatch(await label(), /新会话名称/)
})