mobile-customer-field-controls.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286
  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 model from '../src/views/m/crm/customer/model'
  10. import * as invoiceParser from '../src/utils/crm-invoice-paste-recognize'
  11. function harness(file = 'CustomerFields.vue', values: Record<string, any> = {}, readonly = false) {
  12. const { descriptor } = parse(
  13. readFileSync(new URL(`../src/views/m/crm/customer/${file}`, import.meta.url), 'utf8')
  14. )
  15. const script = compileScript(descriptor, { id: file })
  16. const template = compileTemplate({
  17. id: file,
  18. filename: file,
  19. source: descriptor.template!.content,
  20. compilerOptions: { bindingMetadata: script.bindings }
  21. })
  22. assert.deepEqual(template.errors, [])
  23. const runtime = { ...Vue, resolveComponent: (name: string) => name }
  24. const require = (name: string) =>
  25. name === 'vue'
  26. ? runtime
  27. : name.includes('/dict')
  28. ? {
  29. useDictStore: () => ({
  30. getOptions: () => [{ value: 'normal', label: '普通客户' }],
  31. getTagType: () => 'success'
  32. })
  33. }
  34. : name.includes('invoice-paste')
  35. ? invoiceParser
  36. : name === './model'
  37. ? model
  38. : { default: 'CustomerFields' }
  39. function evaluate(source: string) {
  40. const exports: any = {}
  41. new Function(
  42. 'require',
  43. 'exports',
  44. 'ref',
  45. 'computed',
  46. 'watch',
  47. ts.transpile(source, { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ESNext })
  48. )(require, exports, Vue.ref, Vue.computed, Vue.watch)
  49. return exports
  50. }
  51. const props = Vue.reactive({
  52. fields: model.customerGroups.flatMap((g) => g.fields),
  53. modelValue: values,
  54. readonly,
  55. plainText: false,
  56. users: [
  57. { value: '1', label: '张三' },
  58. { value: '2', label: '李四' }
  59. ]
  60. })
  61. const emitted: any[] = []
  62. const setup = evaluate(script.content).default.setup(props, {
  63. expose() {},
  64. emit: (_: string, value: any) => {
  65. emitted.push(value)
  66. props.modelValue = value
  67. }
  68. })
  69. const render = evaluate(template.code).render
  70. function nodes() {
  71. const found: any[] = []
  72. function visit(node: any) {
  73. if (!node || typeof node !== 'object') return
  74. if (Array.isArray(node)) return node.forEach(visit)
  75. found.push(node)
  76. if (Array.isArray(node.children)) visit(node.children)
  77. else if (node.children && typeof node.children === 'object')
  78. for (const slot of Object.values(node.children))
  79. if (typeof slot === 'function') visit(slot())
  80. }
  81. visit(render({}, [], props, Vue.proxyRefs(setup), {}, {}))
  82. return found
  83. }
  84. return { setup, props, emitted, nodes }
  85. }
  86. const owner = model.customerGroups[2].fields[0]
  87. const date = model.customerGroups[2].fields[1]
  88. test('owner picker search filters labels, retains clear option and resets on cancel/reopen', async () => {
  89. const h = harness()
  90. h.setup.openSelect(owner)
  91. const search = h.nodes().find((n) => n.type === 'van-search')
  92. assert.ok(search, 'picker exposes a search control')
  93. search.props['onUpdate:modelValue']('张')
  94. assert.deepEqual(
  95. h.setup.selectColumns.value.map((c: any) => c.value),
  96. ['', '1']
  97. )
  98. h.nodes()
  99. .find((n) => n.type === 'van-picker')
  100. .props.onCancel()
  101. await Vue.nextTick()
  102. assert.equal(h.setup.selectSearch.value, '')
  103. h.setup.openSelect(owner)
  104. assert.deepEqual(
  105. h.setup.selectColumns.value.map((c: any) => c.value),
  106. ['', '1', '2']
  107. )
  108. h.setup.onSelectConfirm({ selectedValues: ['2'] })
  109. assert.equal(h.props.modelValue.ownerUserId, '2')
  110. h.setup.openSelect(owner)
  111. h.setup.onSelectConfirm({ selectedValues: [''] })
  112. assert.equal(h.props.modelValue.ownerUserId, '')
  113. })
  114. test('datetime retains seconds and old dates, picker exposes seconds and full date range', () => {
  115. const h = harness('CustomerFields.vue', { nextTime: '1990-03-02 10:20:37' })
  116. h.setup.openDate(date)
  117. assert.deepEqual(h.setup.timeParts.value, ['10', '20', '37'])
  118. assert.equal(h.setup.displayDate(date), '1990-03-02 10:20:37')
  119. const nodes = h.nodes()
  120. assert.deepEqual(nodes.find((n) => n.type === 'van-time-picker').props['columns-type'], [
  121. 'hour',
  122. 'minute',
  123. 'second'
  124. ])
  125. const picker = nodes.find((n) => n.type === 'van-date-picker')
  126. assert.ok(picker.props['min-date'].getFullYear() <= 1990)
  127. assert.ok(picker.props['max-date'].getFullYear() >= 1990)
  128. h.setup.onDateConfirm()
  129. assert.equal(model.apiTime(h.props.modelValue.nextTime), '1990-03-02 10:20:37')
  130. })
  131. test('date clear button clears only its field without reopening picker', () => {
  132. const h = harness('CustomerFields.vue', { nextTime: '2026-10-23 10:20:37', remark: '保留' })
  133. const button = h
  134. .nodes()
  135. .find((n) => n.type === 'van-button' && n.props?.['aria-label'] === '清空下次联系时间')
  136. assert.ok(button, 'filled date exposes a clear action')
  137. button.props.onClick({ stopPropagation() {} })
  138. assert.equal(h.props.modelValue.nextTime, '')
  139. assert.equal(h.props.modelValue.remark, '保留')
  140. assert.equal(h.setup.dateOpen.value, false)
  141. })
  142. test('readonly dictionary and next contact time use semantic mobile tags; missing values stay empty', () => {
  143. const h = harness(
  144. 'CustomerFields.vue',
  145. { level: 'normal', nextTime: '2000-01-01 00:00:00' },
  146. true
  147. )
  148. const spans = h.nodes().filter((n) => n.type === 'span')
  149. assert.ok(
  150. spans.some((n) => n.props.class.includes('status-tag--success') && n.children === '普通客户')
  151. )
  152. assert.ok(
  153. spans.some((n) => n.props.class.includes('status-tag--danger') && n.children.includes('2000'))
  154. )
  155. h.props.plainText = true
  156. assert.equal(
  157. h.nodes().filter((n) => n.type === 'span' && n.props?.class?.includes('status-tag')).length,
  158. 0
  159. )
  160. assert.ok(
  161. h.nodes().some((n) => n.type === 'van-cell' && n.props.value === '普通客户'),
  162. 'plain text mode preserves dictionary labels'
  163. )
  164. h.props.modelValue = { level: '', nextTime: '' }
  165. assert.equal(
  166. h.nodes().filter((n) => n.type === 'span' && n.props?.class?.includes('status-tag')).length,
  167. 0
  168. )
  169. assert.equal(h.setup.readonlyText(date), '—')
  170. })
  171. test('clear invoice paste removes input and recognition feedback without clearing filled fields', () => {
  172. const h = harness('InvoiceFields.vue', { companyName: '已填写公司', remark: '保留' })
  173. h.setup.paste.value = '名称:识别公司'
  174. h.setup.recognize()
  175. const before = { ...h.props.modelValue }
  176. const clear = h
  177. .nodes()
  178. .find(
  179. (n) =>
  180. n.type === 'van-button' &&
  181. n.children?.some((v: any) =>
  182. typeof v === 'string' ? v.includes('清空') : v.children?.includes('清空')
  183. )
  184. )
  185. assert.ok(clear, 'paste has a clear button')
  186. clear.props.onClick()
  187. assert.equal(h.setup.paste.value, '')
  188. assert.equal(h.setup.hint.value, '')
  189. assert.deepEqual(h.props.modelValue, before)
  190. assert.equal(h.emitted.length, 1)
  191. })
  192. test('invoice recognition reports auxiliary fields merged into remarks as Web does', () => {
  193. const h = harness('InvoiceFields.vue', { remark: '原备注' })
  194. h.setup.paste.value = '银行行号:105000000000\n邮编:000000'
  195. h.setup.recognize()
  196. assert.equal(h.props.modelValue.remark, '原备注;银行行号:105000000000;邮编:000000')
  197. assert.equal(
  198. h.setup.hint.value,
  199. '已识别 0 个字段,另有 2 项写入备注;未识别到单位名称;未识别到税号;未能识别任何开票字段,请检查格式或手动填写'
  200. )
  201. h.setup.recognize()
  202. assert.equal(h.props.modelValue.remark, '原备注;银行行号:105000000000;邮编:000000')
  203. })
  204. test('today and future contact times follow Web warning semantics', (t) => {
  205. t.mock.timers.enable({ apis: ['Date'], now: new Date('2026-10-23T00:00:00Z') })
  206. const h = harness('CustomerFields.vue', { nextTime: '2026-10-23 23:59:59' }, true)
  207. assert.equal(h.setup.readonlyTagType(date), 'warning')
  208. h.props.modelValue = { nextTime: '2027-01-01 10:00:00' }
  209. assert.equal(h.setup.readonlyTagType(date), undefined)
  210. })
  211. test('date-only confirmation and cancellation preserve existing values correctly', () => {
  212. const birthday = { key: 'birthday', label: '生日', type: 'date' }
  213. const h = harness('CustomerFields.vue', {
  214. birthday: '1985-02-03',
  215. nextTime: '2001-01-02 03:04:55'
  216. })
  217. h.setup.openDate(birthday)
  218. const picker = h.nodes().find((n) => n.type === 'van-date-picker')
  219. picker.props.onCancel()
  220. assert.equal(h.emitted.length, 0)
  221. assert.equal(h.props.modelValue.birthday, '1985-02-03')
  222. h.setup.openDate(birthday)
  223. h.setup.onDateOnlyConfirm()
  224. assert.equal(h.props.modelValue.birthday, '1985-02-03')
  225. assert.equal(h.props.modelValue.nextTime, '2001-01-02 03:04:55')
  226. })
  227. test('real Vant date picker renders bounded options while every business year remains reachable', async () => {
  228. const h = harness('CustomerFields.vue', { nextTime: '1990-03-02 10:20:37' })
  229. h.setup.openDate(date)
  230. async function assertBounded() {
  231. const picker = h.nodes().find((n) => n.type === 'van-date-picker')
  232. const html = await renderToString(
  233. Vue.createSSRApp({
  234. render: () =>
  235. Vue.h(DatePicker, {
  236. modelValue: h.setup.dateParts.value,
  237. minDate: picker.props['min-date'],
  238. maxDate: picker.props['max-date']
  239. })
  240. })
  241. )
  242. const count = (html.match(/<li\b/g) || []).length
  243. assert.ok(count > 0, 'count actual Vant option nodes')
  244. assert.ok(count <= 64, `bounded year/month/day options, received ${count}`)
  245. }
  246. await assertBounded()
  247. for (const year of ['1000', '1850', '2101', '9999']) {
  248. const yearInput = h.nodes().find((n) => n.type === 'van-field' && n.props.label === '年份')
  249. assert.ok(yearInput, 'direct year input reaches dates outside the wheel window')
  250. yearInput.props['onUpdate:modelValue'](year)
  251. yearInput.props.onBlur()
  252. await Vue.nextTick()
  253. assert.equal(h.setup.dateParts.value[0], year)
  254. await assertBounded()
  255. h.setup.onDateConfirm()
  256. assert.equal(model.apiTime(h.props.modelValue.nextTime), `${year}-03-02 10:20:37`)
  257. h.setup.openDate(date)
  258. }
  259. })
  260. test('year input clamps leap day and ignores incomplete years without corrupting the date', async () => {
  261. const h = harness('CustomerFields.vue', { nextTime: '2000-02-29 10:20:37' })
  262. h.setup.openDate(date)
  263. h.setup.yearInput.value = '1900'
  264. h.setup.applyYear()
  265. await Vue.nextTick()
  266. assert.deepEqual(h.setup.dateParts.value, ['1900', '02', '28'])
  267. for (const input of ['', '999', '10000', 'abcd']) {
  268. h.setup.yearInput.value = input
  269. h.setup.applyYear()
  270. assert.equal(h.setup.yearInput.value, '1900')
  271. assert.deepEqual(h.setup.dateParts.value, ['1900', '02', '28'])
  272. }
  273. h.setup.onDateConfirm()
  274. assert.equal(model.apiTime(h.props.modelValue.nextTime), '1900-02-28 10:20:37')
  275. })