import assert from 'node:assert/strict' import test from 'node:test' import { existsSync, readFileSync } from 'node:fs' import { nextTick, effectScope } from 'vue' const file = new URL('../src/views/m/crm/customer/columns.ts', import.meta.url) async function columns() { assert.ok(existsSync(file), 'mobile customer column configuration must exist') return import('../src/views/m/crm/customer/columns') } test('35 optional columns and 14 defaults exactly match Web', async () => { const c = await columns() const web = readFileSync(new URL('../src/views/crm/customer/index.vue', import.meta.url), 'utf8') const defs = web.match(/const listColumnDefs = (\[[\s\S]*?\n\])/)![1] const defaults = web.match(/defaultVisibleProps: (\[[\s\S]*?\])/)![1] assert.deepEqual(c.customerColumnDefs, new Function(`return ${defs}`)()) assert.deepEqual(c.defaultCustomerColumns, new Function(`return ${defaults}`)()) assert.equal(c.customerColumnDefs.length, 35) assert.equal(c.defaultCustomerColumns.length, 14) }) test('hide clears enum/text/numeric/date aliases and paired sort, preserves context and identity', async () => { const c = await columns() const query = { customerNoIn: 'C1', customerNameIn: '客户', mobile: '123', levelIn: 'A', followCount: '2', followCountOp: 'gte', followCountMin: '1', followCountMax: '3', beginTime: '2026-01-01', endTime: '2026-02-01', sortField: 'createTime', sortOrder: 'asc', keyword: '搜索', viewMode: 'mine' } assert.deepEqual(c.cleanCustomerColumnQuery(query, []), { customerNoIn: 'C1', customerNameIn: '客户', keyword: '搜索', viewMode: 'mine' }) assert.deepEqual(c.cleanCustomerColumnQuery(query, ['createTime'], true), { keyword: '搜索', viewMode: 'mine' }) assert.equal(query.mobile, '123') }) test('customer list ignores obsolete field preferences and always shows Web defaults', async () => { const c = await columns() const reads: string[] = [] Object.assign(globalThis, { localStorage: { getItem: (key: string) => { reads.push(key) return '[]' }, setItem: () => {} } }) const h = await harness('index') try { assert.deepEqual(h.api.visibleColumns.value, c.defaultCustomerColumns) assert.deepEqual(reads, [], 'obsolete hidden-column preferences must not be read') for (const name of ['selectMode', 'selected', 'columnsOpen', 'poolOpen']) assert.equal(h.api[name], undefined, `${name} must not remain in the mobile list`) } finally { h.stop() delete (globalThis as any).localStorage } }) import { parse } from '@vue/compiler-sfc' import * as Vue from 'vue' import ts from 'typescript' import * as model from '../src/views/m/crm/customer/model' import { buildCustomerColumnFilterDefs } from '../src/views/crm/customer/columnFilterDefs' async function harness(page: string, props: any = {}) { const c = await columns() const source = readFileSync( new URL(`../src/views/m/crm/customer/${page}.vue`, import.meta.url), 'utf8' ) const script = parse(source).descriptor.scriptSetup!.content const ast = ts.createSourceFile('page.ts', script, ts.ScriptTarget.Latest, true) const statements = ast.statements.filter((s) => !ts.isImportDeclaration(s)) const names = statements.flatMap((s) => ts.isFunctionDeclaration(s) && s.name ? [s.name.text] : ts.isVariableStatement(s) ? s.declarationList.declarations.flatMap((d) => ts.isIdentifier(d.name) ? [d.name.text] : ts.isObjectBindingPattern(d.name) ? d.name.elements.map((e) => e.name.getText(ast)) : [] ) : [] ) const bindings: Record = { ...Vue } for (const s of ast.statements) if (ts.isImportDeclaration(s)) { if (s.importClause?.name) bindings[s.importClause.name.text] = () => {} const n = s.importClause?.namedBindings if (n && ts.isNamedImports(n)) for (const e of n.elements) bindings[e.name.text] = () => {} } const requests: any[] = [], events: any[] = [], navigations: string[] = [] Object.assign(bindings, model, c, { buildCustomerColumnFilterDefs, defineProps: () => props, defineEmits: () => (...args: any[]) => events.push(args), useRouter: () => ({ push: (path: string) => navigations.push(path) }), useDictStore: () => ({ getOptions: () => [], getLabel: (_: any, v: any) => v }), useListReturnState: () => ({ restoring: Vue.ref(false) }), useIntersectionObserver: () => {}, useDebounceFn: (fn: any) => fn, fetchCustomerList: async (q: any) => { requests.push(q) return { rows: [], total: 0 } }, onMounted: () => {}, onBeforeUnmount: () => {} }) for (const key of Object.keys(bindings)) if (!/^[a-zA-Z_$][\w$]*$/.test(key) || key === 'default') delete bindings[key] const code = ts.transpile(statements.map((s) => s.getText(ast)).join('\n'), { target: ts.ScriptTarget.ESNext }) const scope = effectScope() const api = scope.run(() => new Function(...Object.keys(bindings), `${code}; return {${names.join(',')}}`)( ...Object.values(bindings) ) ) return { api, requests, events, navigations, source, stop: () => scope.stop() } } test('fixed customer defaults retain search filters and remove stale non-default filters', async () => { const h = await harness('index') try { assert.ok(h.api.previewFields.value.length) assert.match( h.api.cardMeta({ customerNo: 'C1', status: 'normal', createTime: '2026' }), /C1.*2026/ ) h.api.query.value = { levelIn: 'A', email: 'old hidden field', customerNoIn: 'C1' } await h.api.fetchPage(true) assert.equal(h.requests.at(-1).levelIn, 'A') assert.equal(h.requests.at(-1).customerNoIn, 'C1') assert.equal(h.requests.at(-1).email, undefined) } finally { h.stop() } }) test('filter definitions and drafts track visibility, discarded draft fields never return', async () => { const props = Vue.reactive({ modelValue: false, query: { levelIn: 'A', mobile: '123' }, context: {}, visibleColumns: ['level', 'mobile'] }) const h = await harness('CustomerFilters', props) try { props.modelValue = true await nextTick() await nextTick() assert.deepEqual( h.api.defs.value.map((d: any) => d.prop), ['customerNo', 'customerName', 'mobile', 'level'] ) props.visibleColumns = [] await nextTick() await nextTick() assert.deepEqual(h.api.buildQuery(), {}) props.visibleColumns = ['level', 'mobile'] await nextTick() await nextTick() assert.deepEqual(h.api.buildQuery(), {}, 'stale props must not rehydrate hidden draft filters') } finally { h.stop() } }) test('unchanged effective filter submissions do not reload the list', async () => { const h = await harness('index') try { h.api.applyFilters({ query: {}, keyword: '', viewMode: 'all' }) assert.equal(h.requests.length, 0) h.api.applyFilters({ query: { mobile: '123' }, keyword: '', viewMode: 'all' }) assert.equal(h.requests.length, 1) h.api.applyFilters({ query: { mobile: '123', email: 'hidden' }, keyword: '', viewMode: 'all' }) assert.equal(h.requests.length, 1) } finally { h.stop() } }) test('actual customer list keeps default business data and omits desktop management controls', async () => { const { compileTemplate } = await import('@vue/compiler-sfc') const { renderToString } = await import('vue/server-renderer') const h = await harness('index') try { h.api.rows.value = [ { id: '1', customerName: '始终名称', customerNo: '始终编号', mobile: '手机号值', status: '状态值', createTime: '创建时间值', email: '邮箱值' } ] h.api.loading.value = false const template = compileTemplate({ id: 'list', filename: 'index.vue', source: parse(h.source).descriptor.template!.content }) assert.deepEqual(template.errors, []) const exports: any = {} const stub = { setup: (_: any, { slots }: any) => () => Vue.h( 'div', Object.values(slots).flatMap((slot: any) => slot()) ) } const require = () => ({ ...Vue, resolveComponent: () => stub }) new Function( 'require', 'exports', ts.transpile(template.code, { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ESNext }) )(require, exports) const render = () => renderToString( Vue.createSSRApp({ setup: () => () => exports.render(Vue.proxyRefs(h.api), []) }) ) const rendered = await render() for (const value of ['始终名称', '始终编号', '手机号值', '状态值', '创建时间值']) assert.ok(rendered.includes(value), value) for (const value of [ '邮箱值', '刷新列表', '字段设置', '批量选择', '清空筛选', '列统计', '放入公海' ]) assert.ok(!rendered.includes(value), value) assert.match(rendered, /新增客户/) assert.doesNotMatch( rendered, /)/, 'create action must not be inside an inert template' ) h.api.goNew() assert.deepEqual(h.navigations, ['/m/crm/customer/new']) h.api.rows.value = [] assert.match(await render(), /新增客户/, 'empty lists must allow creating the first customer') h.api.forbidden.value = true assert.doesNotMatch(await render(), /新增客户/, 'forbidden lists must hide the create action') } finally { h.stop() } })