| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482 |
- 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(), /新会话名称/)
- })
|