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

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