277 lines
9.5 KiB
TypeScript
277 lines
9.5 KiB
TypeScript
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<string, any> = { ...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,
|
|
/<template(?:\s|>)/,
|
|
'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()
|
|
}
|
|
})
|