mobile-crm-category-sort.test.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482
  1. import assert from 'node:assert/strict'
  2. import { readFileSync } from 'node:fs'
  3. import test from 'node:test'
  4. import { compile, computed, createSSRApp, h, nextTick, ref, watch } from 'vue'
  5. import { renderToString } from 'vue/server-renderer'
  6. import { parse } from '@vue/compiler-sfc'
  7. import ts from 'typescript'
  8. import { buildCustomerColumnFilterDefs } from '../src/views/crm/customer/columnFilterDefs'
  9. import { stripSelfColumnParams } from '../src/composables/columnFacetContext'
  10. import { buildPathLabelMapFromTreeRoots } from '../src/utils/tree-path'
  11. const filter = 'views/m/crm/customer/CustomerFilters.vue'
  12. const product = 'components/business/product-form-dialog/index.vue'
  13. const cards = [
  14. 'views/m/crm/quote/detail.vue',
  15. 'views/m/crm/contract/detail.vue'
  16. ]
  17. function source(file: string) {
  18. const descriptor = parse(
  19. readFileSync(new URL('../src/' + file, import.meta.url), 'utf8')
  20. ).descriptor
  21. return {
  22. descriptor,
  23. ast: ts.createSourceFile(file, descriptor.scriptSetup!.content, ts.ScriptTarget.Latest, true)
  24. }
  25. }
  26. function functions(
  27. file: string,
  28. names: string[],
  29. bindings: Record<string, any>,
  30. prelude = ''
  31. ): any {
  32. const { ast } = source(file)
  33. // Contract's existing loader delegates its identity guard. Execute the real dependencies;
  34. // omitting them throws ReferenceError inside the loader's intentionally silent catch.
  35. const methodNames = ['views/m/crm/contract/detail.vue', 'views/m/crm/quote/detail.vue'].includes(file) && names.includes('loadCategoryPaths')
  36. ? [...names, 'isCurrentLoad', 'currentRecordId']
  37. : names
  38. const runtimeBindings = methodNames !== names
  39. ? { disposed: false, props: { embedded: false }, ...bindings }
  40. : bindings
  41. const code = ts.transpile(
  42. ast.statements
  43. .filter((n) => ts.isFunctionDeclaration(n) && methodNames.includes(n.name?.text || ''))
  44. .map((n) => n.getText(ast))
  45. .join('\n'),
  46. { target: ts.ScriptTarget.ESNext }
  47. )
  48. return new Function(
  49. ...Object.keys(runtimeBindings),
  50. `${prelude}\n${code}\nreturn { ${names.map((n) => `${n}: typeof ${n} === 'undefined' ? undefined : ${n}`).join(',')} }`
  51. )(...Object.values(runtimeBindings))
  52. }
  53. function value(file: string, name: string, bindings: Record<string, any>) {
  54. const { ast } = source(file)
  55. const declaration = ast.statements
  56. .filter(ts.isVariableStatement)
  57. .flatMap((n) => n.declarationList.declarations)
  58. .find((n) => n.name.getText(ast) === name)
  59. assert.ok(declaration, `Missing ${name}`)
  60. return new Function(
  61. ...Object.keys(bindings),
  62. ts.transpile(`return ${declaration.initializer!.getText(ast)}`, {
  63. target: ts.ScriptTarget.ESNext
  64. })
  65. )(...Object.values(bindings))
  66. }
  67. async function render(template: string, bindings: Record<string, any>, components = {}) {
  68. const compiled = compile(template)
  69. return renderToString(
  70. createSSRApp({ components, render: () => compiled.call(bindings, bindings, []) })
  71. )
  72. }
  73. function filterState(query = {}, context = {}) {
  74. const defs = ref(buildCustomerColumnFilterDefs(() => [])),
  75. emitted: any[] = [],
  76. requests: any[] = [],
  77. ticks: Array<() => void> = []
  78. const b: any = {
  79. defs,
  80. datetimeDefs: computed(() => defs.value.filter((d) => d.type === 'datetime')),
  81. props: { query, context, modelValue: true },
  82. draft: ref({}),
  83. contextDraft: ref({}),
  84. error: ref(''),
  85. nextTick: (f: () => void) => ticks.push(f),
  86. singleKind: ref('view'),
  87. numberOpProp: ref(''),
  88. singleOpen: ref(false),
  89. apiTime: (s: string) => s.replace('T', ' '),
  90. emit: (...args: any[]) => emitted.push(args),
  91. clearFacets: () => {},
  92. facetKeywords: ref({}),
  93. facetVersions: {},
  94. facetContextVersion: 0,
  95. facetLoading: ref({}),
  96. enumOpen: ref(false),
  97. enumProp: ref(''),
  98. enumRemote: ref(false),
  99. facets: ref({}),
  100. enumOptions: ref([]),
  101. stripSelfColumnParams,
  102. unwrapRows: (x: any) => x,
  103. fetchCustomerColumnFacetValues: async (p: any) => {
  104. requests.push(p)
  105. return []
  106. }
  107. }
  108. const methods = functions(
  109. filter,
  110. [
  111. 'syncFromProps',
  112. 'resetDraft',
  113. 'buildQuery',
  114. 'openSingle',
  115. 'onSingleConfirm',
  116. 'resetAndApply',
  117. 'searchFacet',
  118. 'close'
  119. ],
  120. b,
  121. 'let hydrating = false'
  122. )
  123. return { b, ...methods, emitted, requests, ticks }
  124. }
  125. const fields = [
  126. 'lastTime',
  127. 'nextTime',
  128. 'dealTime',
  129. 'intoPoolTime',
  130. 'claimTime',
  131. 'protectTime',
  132. 'createTime',
  133. 'updateTime'
  134. ]
  135. for (const sortField of fields)
  136. for (const sortOrder of ['asc', 'desc'])
  137. test(`customer drops obsolete sort ${sortField}/${sortOrder}`, () => {
  138. const s = filterState({ sortField, sortOrder, mobile: ' 123 ' })
  139. s.syncFromProps()
  140. assert.deepEqual(s.buildQuery(), { mobile: '123' })
  141. assert.deepEqual(s.emitted, [])
  142. s.ticks.forEach((f: () => void) => f())
  143. assert.deepEqual(s.emitted, [])
  144. })
  145. test('remaining view and number pickers still apply their selected values', () => {
  146. const s = filterState()
  147. s.syncFromProps()
  148. s.openSingle('view')
  149. s.onSingleConfirm({ selectedValues: ['mine'] })
  150. assert.equal(s.b.contextDraft.value.viewMode, 'mine')
  151. s.openSingle('numberOp', 'dealAmount')
  152. s.onSingleConfirm({ selectedValues: ['gte'] })
  153. assert.equal(s.b.draft.value.dealAmountOp, 'gte')
  154. assert.equal(s.b.singleOpen.value, false)
  155. })
  156. test('picker cancel and close do not restore obsolete sort; reset clears; facet strips pagination', async () => {
  157. const s = filterState({ sortField: 'createTime', sortOrder: 'desc' }, { page: 3, pageSize: 20 })
  158. s.syncFromProps()
  159. s.openSingle('view')
  160. s.b.singleOpen.value = false
  161. s.close()
  162. assert.equal(s.buildQuery().sortField, undefined)
  163. await s.searchFacet('customerName', '')
  164. for (const key of ['sortField', 'sortOrder', 'page', 'pageSize'])
  165. assert.equal(s.requests[0][key], undefined)
  166. assert.equal(s.buildQuery().sortField, undefined)
  167. s.resetAndApply()
  168. assert.deepEqual(s.buildQuery(), {})
  169. assert.deepEqual(s.emitted.at(-2), ['apply', { query: {}, keyword: '', viewMode: 'all' }])
  170. assert.match(source(filter).descriptor.template!.content, /@cancel="singleOpen = false"/)
  171. })
  172. test('customer filter UI omits sort controls and keeps the view picker', () => {
  173. const template = source(filter).descriptor.template!.content
  174. assert.doesNotMatch(template, /排序字段|排序方向|openSingle\('sort/)
  175. assert.match(template, /openSingle\('view'\)/)
  176. })
  177. const tree = [
  178. {
  179. id: 1,
  180. categoryName: '化工',
  181. children: [{ id: 2, categoryName: '无机', children: [{ id: 3, categoryName: '同名' }] }]
  182. },
  183. { id: '4', categoryName: '其他', children: [{ id: 5, categoryName: '同名' }] }
  184. ]
  185. const pathMap = buildPathLabelMapFromTreeRoots(tree, {
  186. nameKey: 'categoryName',
  187. idKey: 'id',
  188. childrenKey: 'children'
  189. })
  190. for (const file of cards) {
  191. test(`${file} renders paths without changing snapshots`, async () => {
  192. const categoryPaths = ref(pathMap),
  193. { displayCategory } = functions(file, ['displayCategory'], { categoryPaths })
  194. assert.equal(typeof displayCategory, 'function')
  195. const template = source(file).descriptor.template!.content
  196. const match =
  197. template.match(/<van-field[^>]*label="产品分类"[^>]*\/>/) ||
  198. template.match(
  199. /<div class="mp-wv-kv(?: [^"]*)?"><span>产品分类<\/span><span>[^]*?<\/span><\/div>/
  200. )
  201. assert.ok(match)
  202. for (const [categoryId, categoryName, expected] of [
  203. [3, '旧名', '化工 / 无机 / 同名'],
  204. ['5', '同名', '其他 / 同名'],
  205. ['unknown', '快照', '快照'],
  206. ['', '', '—']
  207. ]) {
  208. const row = { categoryId, categoryName }
  209. const html = await render(
  210. match[0],
  211. { row, p: row, displayCategory },
  212. { VanField: { props: ['modelValue'], setup: (p: any) => () => h('span', p.modelValue) } }
  213. )
  214. assert.ok(html.includes(expected as string), html)
  215. assert.deepEqual(row, { categoryId, categoryName })
  216. }
  217. categoryPaths.value = new Map()
  218. assert.equal(displayCategory({ categoryId: 3, categoryName: '快照' }), '快照')
  219. })
  220. test(`${file} tree failure is harmless and dialog never fetches`, async () => {
  221. let calls = 0
  222. const categoryPaths = ref(new Map())
  223. const b = {
  224. categoryPaths,
  225. isPageVariant: ref(file !== cards[0]),
  226. categoryRequestSeq: 1,
  227. detailRequestSeq: 1,
  228. route: { name: file.includes('/contract/') ? 'MpCrmContractDetail' : 'MpCrmQuoteDetail', path: `/m/crm/${file.includes('/contract/') ? 'contract' : 'quote'}/q`, params: { id: 'q' } },
  229. fetchCategoryTree: async () => {
  230. calls++
  231. throw Error('offline')
  232. },
  233. buildPathLabelMapFromTreeRoots
  234. }
  235. const { loadCategoryPaths } = functions(file, ['loadCategoryPaths'], b)
  236. assert.equal(typeof loadCategoryPaths, 'function')
  237. await loadCategoryPaths(1, 'q')
  238. assert.equal(calls, file === cards[0] ? 0 : 1)
  239. b.isPageVariant.value = true
  240. await loadCategoryPaths(1, 'q')
  241. assert.equal(categoryPaths.value.size, 0)
  242. })
  243. }
  244. test('selected category path is page-only; dropdown name and numeric ID preserved', async () => {
  245. const fragment = source(product).descriptor.template!.content.match(
  246. /<el-tree-select[^]*?(?:\/>|<\/el-tree-select>)/
  247. )![0]
  248. const categoryOptions = ref(tree),
  249. categoryPaths = value(product, 'categoryPaths', {
  250. categoryOptions,
  251. computed,
  252. buildPathLabelMapFromTreeRoots
  253. }),
  254. formData = { categoryId: 3 }
  255. const ElTreeSelect = {
  256. props: ['modelValue', 'data'],
  257. setup:
  258. (p: any, { slots }: any) =>
  259. () =>
  260. h('section', [
  261. h('div', slots.label?.({ label: '同名', value: p.modelValue }) || '同名'),
  262. h('aside', p.data[0].categoryName)
  263. ])
  264. }
  265. const page = await render(
  266. fragment,
  267. {
  268. formData,
  269. categoryOptions: tree,
  270. categoryPaths: categoryPaths.value,
  271. categorySnapshot: null,
  272. isPageVariant: true
  273. },
  274. { ElTreeSelect }
  275. )
  276. const dialog = await render(
  277. fragment,
  278. { formData, categoryOptions: tree, categoryPaths: categoryPaths.value, isPageVariant: false },
  279. { ElTreeSelect }
  280. )
  281. assert.match(page, /化工 \/ 无机 \/ 同名/)
  282. assert.doesNotMatch(dialog, /化工 \/ 无机/)
  283. assert.match(page, /<aside>化工<\/aside>/)
  284. assert.equal(formData.categoryId, 3)
  285. })
  286. for (const file of cards) {
  287. test(`${file} loads CRM tree and ignores stale detail responses`, async () => {
  288. const categoryPaths = ref(new Map())
  289. const b = {
  290. categoryPaths,
  291. isPageVariant: ref(true),
  292. categoryRequestSeq: 2,
  293. detailRequestSeq: 2,
  294. route: { name: file.includes('/contract/') ? 'MpCrmContractDetail' : 'MpCrmQuoteDetail', path: `/m/crm/${file.includes('/contract/') ? 'contract' : 'quote'}/current`, params: { id: 'current' } },
  295. fetchCategoryTree: async () => ({ data: tree }),
  296. buildPathLabelMapFromTreeRoots
  297. }
  298. const { loadCategoryPaths } = functions(file, ['loadCategoryPaths'], b)
  299. if (file !== cards[0]) {
  300. await loadCategoryPaths(1, 'current')
  301. assert.equal(categoryPaths.value.size, 0)
  302. await loadCategoryPaths(2, 'old')
  303. assert.equal(categoryPaths.value.size, 0)
  304. }
  305. await loadCategoryPaths(2, 'current')
  306. assert.equal(categoryPaths.value.get('3'), '化工 / 无机 / 同名')
  307. assert.deepEqual(tree[0].children[0].categoryName, '无机')
  308. const imports = source(file)
  309. .ast.statements.filter(ts.isImportDeclaration)
  310. .map((n) => n.getText())
  311. assert.ok(
  312. imports.some((n) => n.includes('fetchCategoryTree') && n.includes('@/api/crm/product'))
  313. )
  314. })
  315. }
  316. test('actual hydration watcher emits no empty query; subsequent edit still uses 300ms live apply', async () => {
  317. const s = filterState({ sortField: 'createTime', sortOrder: 'asc', mobile: '123' })
  318. const { ast } = source(filter)
  319. const names = ['syncFromProps', 'resetDraft', 'normalizeSortPair', 'buildQuery']
  320. const declarations = ast.statements.filter(
  321. (n) => ts.isFunctionDeclaration(n) && names.includes(n.name?.text || '')
  322. )
  323. const live = ast.statements.find(
  324. (n) =>
  325. ts.isVariableStatement(n) &&
  326. n.declarationList.declarations.some((d) => d.name.getText(ast) === 'scheduleLiveApply')
  327. )!
  328. const watcher = ast.statements.find(
  329. (n) =>
  330. ts.isExpressionStatement(n) &&
  331. ts.isCallExpression(n.expression) &&
  332. n.expression.expression.getText(ast) === 'watch' &&
  333. n.expression.arguments[0].getText(ast).includes('[draft, contextDraft]')
  334. )!
  335. let pending: () => void | undefined
  336. const bindings = {
  337. ...s.b,
  338. rangeError: value(filter, 'rangeError', { ...s.b, computed }),
  339. nextTick,
  340. watch: (...args: any[]) => {
  341. return (watch as any)(...args)
  342. },
  343. useDebounceFn: (fn: () => void, delay: number) => {
  344. assert.equal(delay, 300)
  345. return () => {
  346. pending = fn
  347. }
  348. }
  349. }
  350. const code = ts.transpile(
  351. [
  352. 'let hydrating = false',
  353. ...declarations.map((n) => n.getText(ast)),
  354. live.getText(ast),
  355. 'const stop = ' + watcher.getText(ast),
  356. 'return { syncFromProps, stop }'
  357. ].join('\n'),
  358. { target: ts.ScriptTarget.ESNext }
  359. )
  360. const methods = new Function(...Object.keys(bindings), code)(...Object.values(bindings))
  361. methods.syncFromProps()
  362. await nextTick()
  363. assert.equal(pending, undefined)
  364. assert.deepEqual(s.emitted, [])
  365. s.b.draft.value.mobile = '456'
  366. await nextTick()
  367. assert.ok(pending)
  368. pending!()
  369. assert.deepEqual(s.emitted.at(-1), [
  370. 'apply',
  371. {
  372. query: { mobile: '456' },
  373. keyword: '',
  374. viewMode: 'all'
  375. }
  376. ])
  377. methods.stop()
  378. })
  379. test('page product edit keeps matching category snapshot when tree fails; no stale names or payload additions', async () => {
  380. const categoryOptions = ref<any[]>([])
  381. const categoryPaths = value(product, 'categoryPaths', {
  382. categoryOptions,
  383. computed,
  384. buildPathLabelMapFromTreeRoots
  385. })
  386. const categorySnapshot = ref<{ id: string; name: string } | null>(null)
  387. const props = { productId: 'product-A' }
  388. const formData: any = {}
  389. let response: any = {
  390. id: 'product-A',
  391. productName: '产品',
  392. categoryId: 'cat3',
  393. categoryName: '原叶名'
  394. }
  395. const payloads: any[] = []
  396. const b = {
  397. props,
  398. formData,
  399. categoryOptions,
  400. categoryPaths,
  401. categorySnapshot,
  402. isPageVariant: ref(true),
  403. dialogLoading: ref(false),
  404. fetchCategoryTree: async () => {
  405. throw Error('tree unavailable')
  406. },
  407. fetchProductDetail: async () => ({ data: response }),
  408. ElMessage: { error: () => assert.fail('detail must not fail'), success: () => {} },
  409. emit: () => {},
  410. visible: ref(true),
  411. formRef: ref({ validate: async () => true }),
  412. productImagesUploadRef: ref(null),
  413. submitLoading: ref(false),
  414. isEdit: ref(true),
  415. fetchUpdateProduct: async (p: any) => {
  416. payloads.push(p)
  417. return { data: p }
  418. }
  419. }
  420. const methods = functions(
  421. product,
  422. ['handleOpen', 'loadCategoryOptions', 'resetFormData', 'submitForm'],
  423. b
  424. )
  425. const fragment = source(product).descriptor.template!.content.match(
  426. /<el-tree-select[^]*?(?:\/>|<\/el-tree-select>)/
  427. )![0]
  428. const ElTreeSelect = {
  429. props: ['modelValue'],
  430. setup:
  431. (p: any, { slots }: any) =>
  432. () =>
  433. h(
  434. 'span',
  435. slots.label?.({ value: p.modelValue, label: p.modelValue }) || String(p.modelValue)
  436. )
  437. }
  438. const label = () =>
  439. render(
  440. fragment,
  441. {
  442. ...b,
  443. categoryOptions: categoryOptions.value,
  444. categoryPaths: categoryPaths.value,
  445. categorySnapshot: categorySnapshot.value,
  446. isPageVariant: b.isPageVariant.value
  447. },
  448. { ElTreeSelect }
  449. )
  450. await methods.handleOpen()
  451. assert.deepEqual(categoryOptions.value, [])
  452. assert.match(await label(), /原叶名/)
  453. assert.equal(formData.categoryId, 'cat3')
  454. assert.equal(formData.categoryName, undefined)
  455. await methods.submitForm()
  456. assert.equal(payloads[0].categoryId, 'cat3')
  457. assert.equal(payloads[0].categoryName, undefined)
  458. assert.equal(payloads[0].categorySnapshot, undefined)
  459. formData.categoryId = 'cat4'
  460. assert.doesNotMatch(await label(), /原叶名/)
  461. formData.categoryId = ''
  462. assert.doesNotMatch(await label(), /原叶名/)
  463. methods.resetFormData()
  464. formData.categoryId = 'cat3'
  465. assert.doesNotMatch(await label(), /原叶名/)
  466. props.productId = 'product-B'
  467. response = { id: 'product-B', categoryId: 'cat3', categoryName: '新会话名称' }
  468. await methods.handleOpen()
  469. assert.match(await label(), /新会话名称/)
  470. assert.doesNotMatch(await label(), /原叶名/)
  471. props.productId = ''
  472. await methods.handleOpen()
  473. formData.categoryId = 'cat3'
  474. assert.doesNotMatch(await label(), /新会话名称/)
  475. b.isPageVariant.value = false
  476. props.productId = 'product-B'
  477. await methods.handleOpen()
  478. assert.match(await label(), /cat3/)
  479. assert.doesNotMatch(await label(), /新会话名称/)
  480. })