507 lines
17 KiB
TypeScript
507 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/crm/quote/components/QuoteFormPanel.vue',
|
|
'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)
|
|
const code = ts.transpile(
|
|
ast.statements
|
|
.filter((n) => ts.isFunctionDeclaration(n) && names.includes(n.name?.text || ''))
|
|
.map((n) => n.getText(ast))
|
|
.join('\n'),
|
|
{ target: ts.ScriptTarget.ESNext }
|
|
)
|
|
return new Function(
|
|
...Object.keys(bindings),
|
|
`${prelude}\n${code}\nreturn { ${names.map((n) => `${n}: typeof ${n} === 'undefined' ? undefined : ${n}`).join(',')} }`
|
|
)(...Object.values(bindings))
|
|
}
|
|
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,
|
|
[
|
|
'normalizeSortPair',
|
|
'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 sync/build ${sortField}/${sortOrder}`, () => {
|
|
const s = filterState({ sortField, sortOrder, mobile: ' 123 ' })
|
|
s.syncFromProps()
|
|
assert.deepEqual(s.buildQuery(), { mobile: '123', sortField, sortOrder })
|
|
assert.deepEqual(s.emitted, [])
|
|
s.ticks.forEach((f: () => void) => f())
|
|
assert.deepEqual(s.emitted, [])
|
|
})
|
|
test('sort normalization rejects incomplete/invalid pairs and trims legal values', () => {
|
|
const s = filterState()
|
|
assert.equal(typeof s.normalizeSortPair, 'function')
|
|
for (const q of [
|
|
{},
|
|
{ sortField: 'createTime' },
|
|
{ sortOrder: 'asc' },
|
|
{ sortField: 'beginTime', sortOrder: 'asc' },
|
|
{ sortField: 'createTime', sortOrder: 'sideways' }
|
|
])
|
|
assert.deepEqual(s.normalizeSortPair(q), {})
|
|
assert.deepEqual(s.normalizeSortPair({ sortField: ' createTime ', sortOrder: ' ASC ' }), {
|
|
sortField: 'createTime',
|
|
sortOrder: 'asc'
|
|
})
|
|
})
|
|
test('picker defaults desc, preserves asc when switching, clears both and blocks orphan direction', () => {
|
|
const s = filterState()
|
|
s.syncFromProps()
|
|
s.openSingle('sortOrder')
|
|
assert.equal(s.b.singleOpen.value, false)
|
|
s.openSingle('sortField')
|
|
s.onSingleConfirm({ selectedValues: ['createTime'] })
|
|
assert.deepEqual(s.buildQuery(), { sortField: 'createTime', sortOrder: 'desc' })
|
|
s.openSingle('sortOrder')
|
|
s.onSingleConfirm({ selectedValues: ['asc'] })
|
|
s.openSingle('sortField')
|
|
s.onSingleConfirm({ selectedValues: ['lastTime'] })
|
|
assert.deepEqual(s.buildQuery(), { sortField: 'lastTime', sortOrder: 'asc' })
|
|
s.openSingle('sortField')
|
|
s.onSingleConfirm({ selectedValues: [''] })
|
|
assert.deepEqual(s.buildQuery(), {})
|
|
assert.equal(s.b.draft.value.sortField, undefined)
|
|
assert.equal(s.b.draft.value.sortOrder, undefined)
|
|
})
|
|
test('picker cancel and outer close retain pair; reset clears; facet strips sort/pagination', async () => {
|
|
const s = filterState({ sortField: 'createTime', sortOrder: 'desc' }, { page: 3, pageSize: 20 })
|
|
s.syncFromProps()
|
|
s.openSingle('sortField')
|
|
s.b.singleOpen.value = false
|
|
s.close()
|
|
assert.equal(s.buildQuery().sortField, 'createTime')
|
|
await s.searchFacet('customerName', '')
|
|
for (const key of ['sortField', 'sortOrder', 'page', 'pageSize'])
|
|
assert.equal(s.requests[0][key], undefined)
|
|
assert.equal(s.buildQuery().sortField, 'createTime')
|
|
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('picker UI uses eight datetime prop keys including createTime and default order', () => {
|
|
const s = filterState()
|
|
const options = value(filter, 'sortFieldOptions', { datetimeDefs: s.b.datetimeDefs, computed })
|
|
assert.deepEqual(
|
|
options.value.map((o: any) => o.value),
|
|
['', ...fields]
|
|
)
|
|
assert.equal(options.value[0].label, '默认顺序')
|
|
const template = source(filter).descriptor.template!.content
|
|
assert.match(template, /openSingle\('sortField'\)/)
|
|
assert.match(template, /:disabled="!draft.sortField"/)
|
|
})
|
|
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: { 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: { 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,
|
|
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: { sortField: 'createTime', sortOrder: 'asc', 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(), /新会话名称/)
|
|
})
|