mobile-customer-columns.test.ts 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  1. import assert from 'node:assert/strict'
  2. import test from 'node:test'
  3. import { existsSync, readFileSync } from 'node:fs'
  4. import { nextTick, effectScope } from 'vue'
  5. const file = new URL('../src/views/m/crm/customer/columns.ts', import.meta.url)
  6. async function columns() {
  7. assert.ok(existsSync(file), 'mobile customer column configuration must exist')
  8. return import('../src/views/m/crm/customer/columns')
  9. }
  10. test('35 optional columns and 14 defaults exactly match Web', async () => {
  11. const c = await columns()
  12. const web = readFileSync(new URL('../src/views/crm/customer/index.vue', import.meta.url), 'utf8')
  13. const defs = web.match(/const listColumnDefs = (\[[\s\S]*?\n\])/)![1]
  14. const defaults = web.match(/defaultVisibleProps: (\[[\s\S]*?\])/)![1]
  15. assert.deepEqual(c.customerColumnDefs, new Function(`return ${defs}`)())
  16. assert.deepEqual(c.defaultCustomerColumns, new Function(`return ${defaults}`)())
  17. assert.equal(c.customerColumnDefs.length, 35)
  18. assert.equal(c.defaultCustomerColumns.length, 14)
  19. })
  20. test('hide clears enum/text/numeric/date aliases and paired sort, preserves context and identity', async () => {
  21. const c = await columns()
  22. const query = {
  23. customerNoIn: 'C1',
  24. customerNameIn: '客户',
  25. mobile: '123',
  26. levelIn: 'A',
  27. followCount: '2',
  28. followCountOp: 'gte',
  29. followCountMin: '1',
  30. followCountMax: '3',
  31. beginTime: '2026-01-01',
  32. endTime: '2026-02-01',
  33. sortField: 'createTime',
  34. sortOrder: 'asc',
  35. keyword: '搜索',
  36. viewMode: 'mine'
  37. }
  38. assert.deepEqual(c.cleanCustomerColumnQuery(query, []), {
  39. customerNoIn: 'C1',
  40. customerNameIn: '客户',
  41. keyword: '搜索',
  42. viewMode: 'mine'
  43. })
  44. assert.deepEqual(c.cleanCustomerColumnQuery(query, ['createTime'], true), {
  45. keyword: '搜索',
  46. viewMode: 'mine'
  47. })
  48. assert.equal(query.mobile, '123')
  49. })
  50. test('customer list ignores obsolete field preferences and always shows Web defaults', async () => {
  51. const c = await columns()
  52. const reads: string[] = []
  53. Object.assign(globalThis, {
  54. localStorage: {
  55. getItem: (key: string) => {
  56. reads.push(key)
  57. return '[]'
  58. },
  59. setItem: () => {}
  60. }
  61. })
  62. const h = await harness('index')
  63. try {
  64. assert.deepEqual(h.api.visibleColumns.value, c.defaultCustomerColumns)
  65. assert.deepEqual(reads, [], 'obsolete hidden-column preferences must not be read')
  66. for (const name of ['selectMode', 'selected', 'columnsOpen', 'poolOpen'])
  67. assert.equal(h.api[name], undefined, `${name} must not remain in the mobile list`)
  68. } finally {
  69. h.stop()
  70. delete (globalThis as any).localStorage
  71. }
  72. })
  73. import { parse } from '@vue/compiler-sfc'
  74. import * as Vue from 'vue'
  75. import ts from 'typescript'
  76. import * as model from '../src/views/m/crm/customer/model'
  77. import { buildCustomerColumnFilterDefs } from '../src/views/crm/customer/columnFilterDefs'
  78. async function harness(page: string, props: any = {}) {
  79. const c = await columns()
  80. const source = readFileSync(
  81. new URL(`../src/views/m/crm/customer/${page}.vue`, import.meta.url),
  82. 'utf8'
  83. )
  84. const script = parse(source).descriptor.scriptSetup!.content
  85. const ast = ts.createSourceFile('page.ts', script, ts.ScriptTarget.Latest, true)
  86. const statements = ast.statements.filter((s) => !ts.isImportDeclaration(s))
  87. const names = statements.flatMap((s) =>
  88. ts.isFunctionDeclaration(s) && s.name
  89. ? [s.name.text]
  90. : ts.isVariableStatement(s)
  91. ? s.declarationList.declarations.flatMap((d) =>
  92. ts.isIdentifier(d.name)
  93. ? [d.name.text]
  94. : ts.isObjectBindingPattern(d.name)
  95. ? d.name.elements.map((e) => e.name.getText(ast))
  96. : []
  97. )
  98. : []
  99. )
  100. const bindings: Record<string, any> = { ...Vue }
  101. for (const s of ast.statements)
  102. if (ts.isImportDeclaration(s)) {
  103. if (s.importClause?.name) bindings[s.importClause.name.text] = () => {}
  104. const n = s.importClause?.namedBindings
  105. if (n && ts.isNamedImports(n)) for (const e of n.elements) bindings[e.name.text] = () => {}
  106. }
  107. const requests: any[] = [],
  108. events: any[] = [],
  109. navigations: string[] = []
  110. Object.assign(bindings, model, c, {
  111. buildCustomerColumnFilterDefs,
  112. defineProps: () => props,
  113. defineEmits:
  114. () =>
  115. (...args: any[]) =>
  116. events.push(args),
  117. useRouter: () => ({ push: (path: string) => navigations.push(path) }),
  118. useDictStore: () => ({ getOptions: () => [], getLabel: (_: any, v: any) => v }),
  119. useListReturnState: () => ({ restoring: Vue.ref(false) }),
  120. useIntersectionObserver: () => {},
  121. useDebounceFn: (fn: any) => fn,
  122. fetchCustomerList: async (q: any) => {
  123. requests.push(q)
  124. return { rows: [], total: 0 }
  125. },
  126. onMounted: () => {},
  127. onBeforeUnmount: () => {}
  128. })
  129. for (const key of Object.keys(bindings))
  130. if (!/^[a-zA-Z_$][\w$]*$/.test(key) || key === 'default') delete bindings[key]
  131. const code = ts.transpile(statements.map((s) => s.getText(ast)).join('\n'), {
  132. target: ts.ScriptTarget.ESNext
  133. })
  134. const scope = effectScope()
  135. const api = scope.run(() =>
  136. new Function(...Object.keys(bindings), `${code}; return {${names.join(',')}}`)(
  137. ...Object.values(bindings)
  138. )
  139. )
  140. return { api, requests, events, navigations, source, stop: () => scope.stop() }
  141. }
  142. test('fixed customer defaults retain search filters and remove stale non-default filters', async () => {
  143. const h = await harness('index')
  144. try {
  145. assert.ok(h.api.previewFields.value.length)
  146. assert.match(
  147. h.api.cardMeta({ customerNo: 'C1', status: 'normal', createTime: '2026' }),
  148. /C1.*2026/
  149. )
  150. h.api.query.value = { levelIn: 'A', email: 'old hidden field', customerNoIn: 'C1' }
  151. await h.api.fetchPage(true)
  152. assert.equal(h.requests.at(-1).levelIn, 'A')
  153. assert.equal(h.requests.at(-1).customerNoIn, 'C1')
  154. assert.equal(h.requests.at(-1).email, undefined)
  155. } finally {
  156. h.stop()
  157. }
  158. })
  159. test('filter definitions and drafts track visibility, discarded draft fields never return', async () => {
  160. const props = Vue.reactive({
  161. modelValue: false,
  162. query: { levelIn: 'A', mobile: '123' },
  163. context: {},
  164. visibleColumns: ['level', 'mobile']
  165. })
  166. const h = await harness('CustomerFilters', props)
  167. try {
  168. props.modelValue = true
  169. await nextTick()
  170. await nextTick()
  171. assert.deepEqual(
  172. h.api.defs.value.map((d: any) => d.prop),
  173. ['customerNo', 'customerName', 'mobile', 'level']
  174. )
  175. props.visibleColumns = []
  176. await nextTick()
  177. await nextTick()
  178. assert.deepEqual(h.api.buildQuery(), {})
  179. props.visibleColumns = ['level', 'mobile']
  180. await nextTick()
  181. await nextTick()
  182. assert.deepEqual(h.api.buildQuery(), {}, 'stale props must not rehydrate hidden draft filters')
  183. } finally {
  184. h.stop()
  185. }
  186. })
  187. test('unchanged effective filter submissions do not reload the list', async () => {
  188. const h = await harness('index')
  189. try {
  190. h.api.applyFilters({ query: {}, keyword: '', viewMode: 'all' })
  191. assert.equal(h.requests.length, 0)
  192. h.api.applyFilters({ query: { mobile: '123' }, keyword: '', viewMode: 'all' })
  193. assert.equal(h.requests.length, 1)
  194. h.api.applyFilters({ query: { mobile: '123', email: 'hidden' }, keyword: '', viewMode: 'all' })
  195. assert.equal(h.requests.length, 1)
  196. } finally {
  197. h.stop()
  198. }
  199. })
  200. test('actual customer list keeps default business data and omits desktop management controls', async () => {
  201. const { compileTemplate } = await import('@vue/compiler-sfc')
  202. const { renderToString } = await import('vue/server-renderer')
  203. const h = await harness('index')
  204. try {
  205. h.api.rows.value = [
  206. {
  207. id: '1',
  208. customerName: '始终名称',
  209. customerNo: '始终编号',
  210. mobile: '手机号值',
  211. status: '状态值',
  212. createTime: '创建时间值',
  213. email: '邮箱值'
  214. }
  215. ]
  216. h.api.loading.value = false
  217. const template = compileTemplate({
  218. id: 'list',
  219. filename: 'index.vue',
  220. source: parse(h.source).descriptor.template!.content
  221. })
  222. assert.deepEqual(template.errors, [])
  223. const exports: any = {}
  224. const stub = {
  225. setup:
  226. (_: any, { slots }: any) =>
  227. () =>
  228. Vue.h(
  229. 'div',
  230. Object.values(slots).flatMap((slot: any) => slot())
  231. )
  232. }
  233. const require = () => ({ ...Vue, resolveComponent: () => stub })
  234. new Function(
  235. 'require',
  236. 'exports',
  237. ts.transpile(template.code, {
  238. module: ts.ModuleKind.CommonJS,
  239. target: ts.ScriptTarget.ESNext
  240. })
  241. )(require, exports)
  242. const render = () =>
  243. renderToString(
  244. Vue.createSSRApp({ setup: () => () => exports.render(Vue.proxyRefs(h.api), []) })
  245. )
  246. const rendered = await render()
  247. for (const value of ['始终名称', '始终编号', '手机号值', '状态值', '创建时间值'])
  248. assert.ok(rendered.includes(value), value)
  249. for (const value of [
  250. '邮箱值',
  251. '刷新列表',
  252. '字段设置',
  253. '批量选择',
  254. '清空筛选',
  255. '列统计',
  256. '放入公海'
  257. ])
  258. assert.ok(!rendered.includes(value), value)
  259. assert.match(rendered, /新增客户/)
  260. assert.doesNotMatch(
  261. rendered,
  262. /<template(?:\s|>)/,
  263. 'create action must not be inside an inert template'
  264. )
  265. h.api.goNew()
  266. assert.deepEqual(h.navigations, ['/m/crm/customer/new'])
  267. h.api.rows.value = []
  268. assert.match(await render(), /新增客户/, 'empty lists must allow creating the first customer')
  269. h.api.forbidden.value = true
  270. assert.doesNotMatch(await render(), /新增客户/, 'forbidden lists must hide the create action')
  271. } finally {
  272. h.stop()
  273. }
  274. })