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, 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) { 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, 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(/]*label="产品分类"[^>]*\/>/) || template.match( /
产品分类<\/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>)/ )![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, /