mobile-customer-filter-dates.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347
  1. import assert from 'node:assert/strict'
  2. import { readFileSync } from 'node:fs'
  3. import test from 'node:test'
  4. import * as Vue from 'vue'
  5. import { renderToString } from 'vue/server-renderer'
  6. import { DatePicker } from 'vant'
  7. import { compileScript, compileTemplate, parse } from '@vue/compiler-sfc'
  8. import ts from 'typescript'
  9. import * as columns from '../src/views/m/crm/customer/columns'
  10. import * as model from '../src/views/m/crm/customer/model'
  11. import * as definitions from '../src/views/crm/customer/columnFilterDefs'
  12. function harness(query: Record<string, string> = {}) {
  13. const messages: string[] = []
  14. let liveApply: () => void = () => {}
  15. const file = 'CustomerFilters.vue'
  16. const { descriptor } = parse(
  17. readFileSync(new URL(`../src/views/m/crm/customer/${file}`, import.meta.url), 'utf8')
  18. )
  19. const script = compileScript(descriptor, { id: file })
  20. const template = compileTemplate({
  21. id: file,
  22. filename: file,
  23. source: descriptor.template!.content,
  24. compilerOptions: { bindingMetadata: script.bindings }
  25. })
  26. assert.deepEqual(template.errors, [])
  27. const require = (name: string) =>
  28. name === 'vue'
  29. ? { ...Vue, resolveComponent: (name: string) => name }
  30. : name.includes('/dict')
  31. ? { useDictStore: () => ({ getOptions: () => [] }) }
  32. : name.includes('columnFilterDefs')
  33. ? definitions
  34. : name === './columns'
  35. ? columns
  36. : name === './model'
  37. ? model
  38. : name === '@vueuse/core'
  39. ? {
  40. useDebounceFn: (callback: () => void) => {
  41. liveApply = callback
  42. return () => {}
  43. }
  44. }
  45. : name === 'vant'
  46. ? { showFailToast: (message: string) => messages.push(message) }
  47. : { default: 'MpStructSkeleton' }
  48. function evaluate(source: string) {
  49. const exports: any = {}
  50. new Function(
  51. 'require',
  52. 'exports',
  53. 'ref',
  54. 'computed',
  55. 'watch',
  56. 'nextTick',
  57. ts.transpile(source, { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ESNext })
  58. )(require, exports, Vue.ref, Vue.computed, Vue.watch, Vue.nextTick)
  59. return exports
  60. }
  61. const props = Vue.reactive({ modelValue: true, query, context: {} })
  62. const emitted: any[] = []
  63. const scope = Vue.effectScope()
  64. const setup = scope.run(() =>
  65. evaluate(script.content).default.setup(props, {
  66. expose() {},
  67. emit: (...args: any[]) => emitted.push(args)
  68. })
  69. )
  70. setup.syncFromProps()
  71. const render = evaluate(template.code).render
  72. function nodes() {
  73. const found: any[] = []
  74. function visit(node: any) {
  75. if (!node || typeof node !== 'object') return
  76. if (Array.isArray(node)) return node.forEach(visit)
  77. found.push(node)
  78. if (Array.isArray(node.children)) visit(node.children)
  79. else if (node.children && typeof node.children === 'object')
  80. for (const slot of Object.values(node.children))
  81. if (typeof slot === 'function') visit(slot())
  82. }
  83. visit(render({}, [], props, Vue.proxyRefs(setup), {}, {}))
  84. return found
  85. }
  86. return {
  87. setup,
  88. emitted,
  89. nodes,
  90. props,
  91. messages,
  92. runLive: () => liveApply(),
  93. stop: () => scope.stop()
  94. }
  95. }
  96. test('every reversed numeric range blocks explicit and live application without losing the draft', async () => {
  97. for (const def of definitions
  98. .buildCustomerColumnFilterDefs(() => [])
  99. .filter((d) => d.type === 'number')) {
  100. const h = harness()
  101. try {
  102. await Vue.nextTick()
  103. h.setup.draft.value[def.prop + 'Min'] = '10'
  104. h.setup.draft.value[def.prop + 'Max'] = '2'
  105. h.runLive()
  106. h.setup.apply()
  107. assert.deepEqual(h.emitted, [], def.prop + ': invalid range must not submit or close')
  108. assert.equal(h.setup.draft.value[def.prop + 'Min'], '10')
  109. assert.equal(h.setup.draft.value[def.prop + 'Max'], '2')
  110. assert.equal(h.messages.at(-1), def.label + ':最小值不能大于最大值')
  111. assert.ok(h.nodes().some((node) => node.type === 'p' && node.props?.role === 'alert'))
  112. } finally {
  113. h.stop()
  114. }
  115. }
  116. })
  117. test('every reversed date range blocks explicit and live application while retaining both dates', async () => {
  118. for (const def of definitions
  119. .buildCustomerColumnFilterDefs(() => [])
  120. .filter((d) => d.type === 'datetime')) {
  121. const h = harness()
  122. try {
  123. await Vue.nextTick()
  124. const from = def.queryKeyFrom || def.prop + 'From'
  125. const to = def.queryKeyTo || def.prop + 'To'
  126. h.setup.draft.value[from] = '2026-10-24'
  127. h.setup.draft.value[to] = '2026-10-23'
  128. h.runLive()
  129. h.setup.apply()
  130. assert.deepEqual(h.emitted, [], def.prop + ': invalid range must not submit or close')
  131. assert.equal(h.setup.draft.value[from], '2026-10-24')
  132. assert.equal(h.setup.draft.value[to], '2026-10-23')
  133. assert.equal(h.messages.at(-1), def.label + ':开始日期不能晚于结束日期')
  134. } finally {
  135. h.stop()
  136. }
  137. }
  138. })
  139. test('valid equal, single-sided, zero and decimal ranges remain usable; inactive bounds are ignored', async () => {
  140. const h = harness()
  141. try {
  142. await Vue.nextTick()
  143. for (const [min, max] of [
  144. ['', ''],
  145. ['0', ''],
  146. ['', '2'],
  147. ['0', '0'],
  148. ['0.1', '0.2'],
  149. ['-2', '-1']
  150. ]) {
  151. h.emitted.length = 0
  152. Object.assign(h.setup.draft.value, {
  153. dealAmountOp: 'between',
  154. dealAmountMin: min,
  155. dealAmountMax: max
  156. })
  157. h.setup.apply()
  158. assert.ok(
  159. h.emitted.some(([event]) => event === 'apply'),
  160. `${min}..${max}`
  161. )
  162. }
  163. for (const op of ['eq', 'gt', 'gte', 'lt', 'lte']) {
  164. h.emitted.length = 0
  165. Object.assign(h.setup.draft.value, {
  166. dealAmountOp: op,
  167. dealAmount: '0',
  168. dealAmountMin: '2',
  169. dealAmountMax: '1'
  170. })
  171. h.setup.apply()
  172. assert.deepEqual(h.emitted.find(([event]) => event === 'apply')[1].query, {
  173. dealAmountOp: op,
  174. dealAmount: '0'
  175. })
  176. }
  177. for (const [from, to] of [
  178. ['', ''],
  179. ['2026-10-24', ''],
  180. ['', '2026-10-24'],
  181. ['2026-10-24', '2026-10-24']
  182. ]) {
  183. h.emitted.length = 0
  184. Object.assign(h.setup.draft.value, { createTimeFrom: from, createTimeTo: to })
  185. h.setup.apply()
  186. assert.ok(
  187. h.emitted.some(([event]) => event === 'apply'),
  188. `${from}..${to}`
  189. )
  190. }
  191. assert.deepEqual(h.messages, [])
  192. } finally {
  193. h.stop()
  194. }
  195. })
  196. test('correcting a reversed range clears its message and resumes live filtering; closed panels stay inactive', async () => {
  197. const h = harness()
  198. try {
  199. await Vue.nextTick()
  200. Object.assign(h.setup.draft.value, { followCountMin: '2', followCountMax: '1', mobile: '199' })
  201. h.setup.apply()
  202. assert.deepEqual(h.emitted, [])
  203. h.setup.draft.value.followCountMax = '2'
  204. h.runLive()
  205. assert.deepEqual(h.emitted.find(([event]) => event === 'apply')[1].query, {
  206. mobile: '199',
  207. followCountOp: 'between',
  208. followCountMin: '2',
  209. followCountMax: '2'
  210. })
  211. assert.ok(!h.nodes().some((node) => node.type === 'p' && node.props?.role === 'alert'))
  212. h.emitted.length = 0
  213. h.props.modelValue = false
  214. h.runLive()
  215. assert.deepEqual(h.emitted, [])
  216. } finally {
  217. h.stop()
  218. }
  219. })
  220. test('date range selection sends Web date-only inclusive boundaries, without time controls', () => {
  221. const h = harness()
  222. try {
  223. h.setup.openDateTime('nextTimeFrom')
  224. h.setup.dateValue.value = ['2026', '10', '23']
  225. h.setup.onDateConfirm()
  226. assert.equal(h.setup.draft.value.nextTimeFrom, '2026-10-23')
  227. h.setup.openDateTime('nextTimeTo')
  228. h.setup.dateValue.value = ['2026', '10', '23']
  229. h.setup.onDateConfirm()
  230. assert.equal(h.nodes().filter((n) => n.type === 'van-time-picker').length, 0)
  231. assert.deepEqual(h.setup.buildQuery(), { nextTimeFrom: '2026-10-23', nextTimeTo: '2026-10-23' })
  232. h.setup.apply()
  233. assert.deepEqual(h.emitted.find(([event]) => event === 'apply')[1].query, {
  234. nextTimeFrom: '2026-10-23',
  235. nextTimeTo: '2026-10-23'
  236. })
  237. } finally {
  238. h.stop()
  239. }
  240. })
  241. test('hydrated legacy date-times normalize to date-only for every defined date boundary', () => {
  242. const defs = definitions
  243. .buildCustomerColumnFilterDefs(() => [])
  244. .filter((d) => d.type === 'datetime')
  245. const query = Object.fromEntries(
  246. defs
  247. .flatMap((d) => [d.queryKeyFrom || d.prop + 'From', d.queryKeyTo || d.prop + 'To'])
  248. .map((key) => [key, '1985-02-03 12:34:56'])
  249. )
  250. const h = harness(query)
  251. try {
  252. for (const key of Object.keys(query)) {
  253. assert.equal(h.setup.draft.value[key], '1985-02-03')
  254. assert.equal(h.setup.buildQuery()[key], '1985-02-03')
  255. }
  256. assert.equal(h.setup.displayDateTime('1985-02-03T12:34:56'), '1985-02-03')
  257. } finally {
  258. h.stop()
  259. }
  260. })
  261. test('each date boundary exposes a clear button that preserves its opposite and other filters', () => {
  262. const h = harness({ nextTimeFrom: '2026-10-01', nextTimeTo: '2026-10-23', mobile: '138' })
  263. try {
  264. for (const suffix of ['From', 'To']) {
  265. const label = suffix === 'From' ? '清空下次联系时间起始日期' : '清空下次联系时间截止日期'
  266. const button = h
  267. .nodes()
  268. .find((n) => n.type === 'van-button' && n.props?.['aria-label'] === label)
  269. assert.ok(button, label)
  270. button.props.onClick({ stopPropagation() {} })
  271. assert.equal(h.setup.buildQuery()['nextTime' + suffix], undefined)
  272. assert.equal(h.setup.buildQuery().mobile, '138')
  273. assert.equal(h.setup.dateOpen.value, false)
  274. if (suffix === 'From') assert.equal(h.setup.buildQuery().nextTimeTo, '2026-10-23')
  275. }
  276. } finally {
  277. h.stop()
  278. }
  279. })
  280. test('historical and distant filter dates are reachable with bounded real Vant rendering', async () => {
  281. const h = harness({ nextTimeFrom: '1850-03-02' })
  282. try {
  283. h.setup.openDateTime('nextTimeFrom')
  284. assert.deepEqual(h.setup.dateValue.value, ['1850', '03', '02'])
  285. for (const year of ['1000', '1850', '2101', '9999']) {
  286. const input = h.nodes().find((n) => n.type === 'van-field' && n.props.label === '年份')
  287. assert.ok(input, 'full year range available via year input')
  288. input.props['onUpdate:modelValue'](year)
  289. input.props.onBlur()
  290. await Vue.nextTick()
  291. const picker = h.nodes().find((n) => n.type === 'van-date-picker')
  292. const html = await renderToString(
  293. Vue.createSSRApp({
  294. render: () =>
  295. Vue.h(DatePicker, {
  296. modelValue: h.setup.dateValue.value,
  297. minDate: picker.props['min-date'],
  298. maxDate: picker.props['max-date']
  299. })
  300. })
  301. )
  302. const count = (html.match(/<li\b/g) || []).length
  303. assert.ok(count > 0 && count <= 64, `actual Vant option count: ${count}`)
  304. h.setup.onDateConfirm()
  305. assert.equal(h.setup.buildQuery().nextTimeFrom, `${year}-03-02`)
  306. h.setup.openDateTime('nextTimeFrom')
  307. }
  308. } finally {
  309. h.stop()
  310. }
  311. })
  312. test('cancel leaves the range intact; year edits handle leap days and incomplete input', async () => {
  313. const h = harness({ nextTimeFrom: '2000-02-29', nextTimeTo: '2026-10-23' })
  314. try {
  315. h.setup.openDateTime('nextTimeFrom')
  316. h.setup.yearInput.value = '1900'
  317. h.setup.applyYear()
  318. await Vue.nextTick()
  319. assert.deepEqual(h.setup.dateValue.value, ['1900', '02', '28'])
  320. h.nodes()
  321. .find((n) => n.type === 'van-date-picker')
  322. .props.onCancel()
  323. assert.equal(h.setup.buildQuery().nextTimeFrom, '2000-02-29')
  324. h.setup.openDateTime('nextTimeFrom')
  325. assert.deepEqual(h.setup.dateValue.value, ['2000', '02', '29'])
  326. for (const input of ['', '999', '10000', 'abcd']) {
  327. h.setup.yearInput.value = input
  328. h.setup.applyYear()
  329. assert.equal(h.setup.yearInput.value, '2000')
  330. assert.deepEqual(h.setup.dateValue.value, ['2000', '02', '29'])
  331. }
  332. h.setup.onDateConfirm()
  333. assert.deepEqual(h.setup.buildQuery(), { nextTimeFrom: '2000-02-29', nextTimeTo: '2026-10-23' })
  334. } finally {
  335. h.stop()
  336. }
  337. })