mobile-customer-follow-save.test.ts 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. import assert from 'node:assert/strict'
  2. import { readFileSync } from 'node:fs'
  3. import test from 'node:test'
  4. import { ref } from 'vue'
  5. import { parse } from '@vue/compiler-sfc'
  6. import ts from 'typescript'
  7. import { apiTime, unwrapRows } from '../src/views/m/crm/customer/model'
  8. const file = new URL('../src/views/m/crm/customer/FollowPanel.vue', import.meta.url)
  9. const { descriptor } = parse(readFileSync(file, 'utf8'))
  10. const ast = ts.createSourceFile(
  11. 'FollowPanel.ts',
  12. descriptor.scriptSetup!.content,
  13. ts.ScriptTarget.Latest,
  14. true
  15. )
  16. const states = [
  17. 'rows',
  18. 'contacts',
  19. 'loading',
  20. 'acting',
  21. 'editing',
  22. 'error',
  23. 'saveError',
  24. 'form',
  25. 'upload'
  26. ]
  27. const methods = ['open', 'save', 'load']
  28. const source = ast.statements
  29. .filter((node) => {
  30. if (ts.isVariableStatement(node)) {
  31. return states.includes(node.declarationList.declarations[0].name.getText(ast))
  32. }
  33. return (
  34. ts.isFunctionDeclaration(node) &&
  35. [...methods, 'showSaveError'].includes(node.name?.text || '')
  36. )
  37. })
  38. .map((node) => node.getText(ast))
  39. .join('\n')
  40. // Execute the real component state, open/save/load functions and date formatter.
  41. // Only transport and the attachment component are stubbed; no API or browser is used.
  42. function harness(create: () => Promise<unknown> = async () => undefined) {
  43. const payloads: Record<string, any>[] = []
  44. const events: string[] = []
  45. const notices: string[] = []
  46. let reloads = 0
  47. const bindings = {
  48. ref,
  49. // This payload fixture stays on one customer; session transitions are covered by renderer tests.
  50. identity: {
  51. newSession() {},
  52. begin: () => ({ id: 'customer-test', valid: () => true })
  53. },
  54. apiTime,
  55. unwrapRows,
  56. showFailToast: (message: string) => notices.push(message),
  57. props: { customerId: 'customer-test' },
  58. emit: (event: string) => events.push(event),
  59. fetchContactByCustomerId: async () => [],
  60. fetchFollowByCustomerId: async () => {
  61. reloads++
  62. return [{ id: 'saved-follow' }]
  63. },
  64. fetchCreateCustomerFollow: async (payload: Record<string, any>) => {
  65. payloads.push(payload)
  66. return create()
  67. }
  68. }
  69. const code = ts.transpile(source, { target: ts.ScriptTarget.ESNext })
  70. const instance = new Function(
  71. ...Object.keys(bindings),
  72. `${code}\nreturn { ${[...states, ...methods].join(', ')} }`
  73. )(...Object.values(bindings))
  74. return { ...instance, payloads, events, notices, reloads: () => reloads }
  75. }
  76. async function draft(h: ReturnType<typeof harness>) {
  77. await h.open()
  78. Object.assign(h.form.value, {
  79. followContent: '联系内容',
  80. attachment: 'fixture.pdf',
  81. remark: '保留备注'
  82. })
  83. }
  84. test('an omitted optional next contact date is absent from the serialized save payload', async () => {
  85. const h = harness()
  86. await draft(h)
  87. assert.equal(h.form.value.nextTime, '')
  88. await h.save()
  89. assert.equal(h.payloads.length, 1)
  90. assert.equal(h.payloads[0].nextTime, undefined)
  91. assert.equal(Object.hasOwn(JSON.parse(JSON.stringify(h.payloads[0])), 'nextTime'), false)
  92. assert.equal(h.payloads[0].customerId, 'customer-test')
  93. assert.equal(h.payloads[0].attachment, 'fixture.pdf')
  94. assert.equal(h.editing.value, false)
  95. assert.equal(h.acting.value, false)
  96. assert.deepEqual(h.events, ['changed'])
  97. assert.equal(h.reloads(), 1)
  98. assert.equal(h.rows.value[0].id, 'saved-follow')
  99. })
  100. test('null and missing optional next contact dates never serialize as empty strings', async () => {
  101. for (const value of [null, undefined]) {
  102. const h = harness()
  103. await draft(h)
  104. h.form.value.nextTime = value
  105. await h.save()
  106. assert.equal(h.payloads[0].nextTime, undefined)
  107. assert.equal(Object.hasOwn(JSON.parse(JSON.stringify(h.payloads[0])), 'nextTime'), false)
  108. }
  109. })
  110. test('a valid local date retains the selected time and gains API seconds only when needed', async () => {
  111. for (const [input, expected] of [
  112. ['2026-10-22T09:30', '2026-10-22 09:30:00'],
  113. ['2026-10-22T09:30:45', '2026-10-22 09:30:45']
  114. ]) {
  115. const h = harness()
  116. await draft(h)
  117. h.form.value.nextTime = input
  118. await h.save()
  119. assert.equal(h.payloads[0].nextTime, expected)
  120. assert.equal(h.form.value.nextTime, input)
  121. }
  122. })
  123. test('clearing a previously selected date submits no stale date or empty datetime', async () => {
  124. const h = harness()
  125. await draft(h)
  126. h.form.value.nextTime = '2026-10-22T09:30'
  127. h.form.value.nextTime = ''
  128. await h.save()
  129. assert.equal(h.payloads[0].nextTime, undefined)
  130. assert.equal(h.form.value.nextTime, '')
  131. })
  132. test('an uploading attachment blocks submission and leaves the draft open', async () => {
  133. const h = harness()
  134. await draft(h)
  135. h.upload.value = { hasUploading: () => true }
  136. const before = { ...h.form.value }
  137. await h.save()
  138. assert.equal(h.payloads.length, 0)
  139. assert.equal(h.saveError.value, '附件上传中,请稍候')
  140. assert.equal(h.editing.value, true)
  141. assert.equal(h.acting.value, false)
  142. assert.deepEqual(h.form.value, before)
  143. assert.equal(h.reloads(), 0)
  144. assert.deepEqual(h.events, [])
  145. })
  146. test('a failed save retains all inputs and allows a corrected retry', async () => {
  147. let failure = true
  148. const h = harness(async () => {
  149. if (failure) throw new Error('fixture save failure')
  150. })
  151. await draft(h)
  152. h.form.value.nextTime = '2026-10-22T09:30'
  153. const before = { ...h.form.value }
  154. await h.save()
  155. assert.equal(h.saveError.value, 'fixture save failure')
  156. assert.equal(h.editing.value, true)
  157. assert.equal(h.acting.value, false)
  158. assert.deepEqual(h.form.value, before)
  159. assert.deepEqual(h.events, [])
  160. assert.equal(h.reloads(), 0)
  161. failure = false
  162. h.form.value.nextTime = ''
  163. await h.save()
  164. assert.equal(h.payloads.length, 2)
  165. assert.equal(h.payloads[1].nextTime, undefined)
  166. assert.equal(h.saveError.value, '')
  167. assert.equal(h.editing.value, false)
  168. })
  169. test('a pending save blocks duplicate submission until it settles', async () => {
  170. let resolve!: () => void
  171. const h = harness(
  172. () =>
  173. new Promise<void>((done) => {
  174. resolve = done
  175. })
  176. )
  177. await draft(h)
  178. const pending = h.save()
  179. assert.equal(h.acting.value, true)
  180. await h.save()
  181. assert.equal(h.payloads.length, 1)
  182. resolve()
  183. await pending
  184. assert.equal(h.acting.value, false)
  185. })
  186. test('required content validation still runs before transport', async () => {
  187. const h = harness()
  188. await h.open()
  189. h.form.value.followContent = ' '
  190. await h.save()
  191. assert.equal(h.payloads.length, 0)
  192. assert.equal(h.saveError.value, '跟进内容不能为空')
  193. assert.equal(h.editing.value, true)
  194. })
  195. test('follow save pairs the selected contact ID with its loaded canonical name', async () => {
  196. for (const selectedId of [42, '42']) {
  197. const h = harness()
  198. await draft(h)
  199. h.contacts.value = [{ id: 42, contactName: '当前联系人' }]
  200. h.form.value.contactId = selectedId
  201. h.form.value.contactName = '过期姓名'
  202. await h.save()
  203. assert.equal(h.payloads[0].contactName, '当前联系人')
  204. assert.equal(String(h.payloads[0].contactId), '42')
  205. }
  206. })
  207. test('cleared and unknown follow contacts never submit a stale name', async () => {
  208. for (const selectedId of ['', null, undefined, 'unknown']) {
  209. const h = harness()
  210. await draft(h)
  211. h.contacts.value = [{ id: 'known', contactName: '旧联系人' }]
  212. h.form.value.contactId = selectedId
  213. h.form.value.contactName = '过期姓名'
  214. await h.save()
  215. assert.equal(h.payloads[0].contactName, '')
  216. }
  217. })
  218. test('a cleared follow type blocks transport with visible field feedback and retains the draft', async () => {
  219. for (const value of ['', ' ', null, undefined]) {
  220. const h = harness()
  221. await draft(h)
  222. h.form.value.followType = value
  223. const before = { ...h.form.value }
  224. await h.save()
  225. assert.equal(h.payloads.length, 0)
  226. assert.equal(h.saveError.value, '跟进方式不能为空')
  227. assert.equal(h.notices.at(-1), h.saveError.value)
  228. assert.deepEqual(h.form.value, before)
  229. assert.equal(h.editing.value, true)
  230. assert.equal(h.acting.value, false)
  231. assert.equal(h.reloads(), 0)
  232. }
  233. })