mobile-customer-validation.test.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646
  1. import assert from 'node:assert/strict'
  2. import { readFileSync } from 'node:fs'
  3. import { createRequire } from 'node:module'
  4. import test from 'node:test'
  5. import { computed, ref } from 'vue'
  6. import { compileScript, compileTemplate, parse } from '@vue/compiler-sfc'
  7. import ts from 'typescript'
  8. import * as model from '../src/views/m/crm/customer/model'
  9. import * as validation from '../src/views/m/crm/customer/validation'
  10. const base = new URL('../src/views/m/crm/customer/', import.meta.url)
  11. test('customer and contact native submissions reach the same explicit validation as save buttons', () => {
  12. for (const file of ['form.vue', 'ContactPanel.vue']) {
  13. const { descriptor } = parse(readFileSync(new URL(file, base), 'utf8'))
  14. assert.match(
  15. descriptor.template!.content,
  16. /<form\b[^>]*\bnovalidate\b[^>]*@submit\.prevent="save"/
  17. )
  18. }
  19. })
  20. // Execute the current production declarations; stub only transport, navigation and UI feedback.
  21. function execute(file: string, names: string[], bindings: Record<string, any>) {
  22. // Validation fixtures keep one record/session; identity transitions have renderer coverage separately.
  23. bindings = {
  24. identity: {
  25. begin: () => ({ id: String(bindings.props?.customerId || bindings.props?.opportunity?.id || ''), valid: () => true })
  26. },
  27. ...validation,
  28. ...bindings
  29. }
  30. const { descriptor } = parse(readFileSync(new URL(file, base), 'utf8'))
  31. const ast = ts.createSourceFile(
  32. file,
  33. descriptor.scriptSetup!.content,
  34. ts.ScriptTarget.Latest,
  35. true
  36. )
  37. const selected = [...names, 'showSaveError']
  38. const source = ast.statements
  39. .filter((node) =>
  40. ts.isFunctionDeclaration(node)
  41. ? selected.includes(node.name?.text || '')
  42. : ts.isVariableStatement(node) &&
  43. selected.includes(node.declarationList.declarations[0].name.getText(ast))
  44. )
  45. .map((node) => node.getText(ast))
  46. .join('\n')
  47. return new Function(
  48. ...Object.keys(bindings),
  49. `${ts.transpile(source, { target: ts.ScriptTarget.ESNext })}\nreturn { ${names.join(',')} }`
  50. )(...Object.values(bindings))
  51. }
  52. function feedback() {
  53. const notices: string[] = []
  54. const events: string[] = []
  55. return {
  56. notices,
  57. events,
  58. showFailToast: (message: string) => notices.push(message),
  59. emit: (event: string) => events.push(event)
  60. }
  61. }
  62. function contactHarness(edit: boolean, patch: model.Row = {}, write = async () => {}) {
  63. const payloads: model.Row[] = []
  64. const routes: string[] = []
  65. let reloads = 0
  66. const bindings = {
  67. ...model,
  68. ...feedback(),
  69. acting: ref(false),
  70. saveError: ref(''),
  71. editing: ref(true),
  72. form: ref({
  73. contactName: '测试联系人',
  74. mobile: '13800138000',
  75. telephone: '前台转 123 / 工作日',
  76. email: '',
  77. isKey: 1,
  78. isPrimary: 0,
  79. remark: '保留草稿',
  80. ...(edit ? { id: 'contact-1' } : {}),
  81. ...patch
  82. }),
  83. props: { customerId: 'customer-1' },
  84. fetchCreateContact: async (data: model.Row) => {
  85. payloads.push(data)
  86. routes.push('create')
  87. await write()
  88. },
  89. fetchUpdateContact: async (data: model.Row) => {
  90. payloads.push(data)
  91. routes.push('update')
  92. await write()
  93. },
  94. load: async () => {
  95. reloads++
  96. }
  97. }
  98. return {
  99. ...bindings,
  100. ...execute('ContactPanel.vue', ['save'], bindings),
  101. payloads,
  102. routes,
  103. reloads: () => reloads
  104. }
  105. }
  106. for (const edit of [false, true]) {
  107. for (const [key, value, message] of [
  108. ['contactName', ' ', '联系人姓名'],
  109. ['mobile', '', '手机号'],
  110. ['mobile', '123', '手机号'],
  111. ['mobile', '12800138000', '手机号'],
  112. ['mobile', '138001380000', '手机号'],
  113. ['email', 'bad', '邮箱'],
  114. ['email', 'a@b', '邮箱']
  115. ]) {
  116. test(`contact ${edit ? 'edit' : 'create'} rejects ${key}=${value} before transport`, async () => {
  117. const h = contactHarness(edit, { [key]: value })
  118. const before = { ...h.form.value }
  119. await h.save()
  120. assert.equal(h.payloads.length, 0)
  121. assert.match(h.saveError.value, new RegExp(message))
  122. assert.equal(h.notices.at(-1), h.saveError.value)
  123. assert.deepEqual(h.form.value, before)
  124. assert.equal(h.editing.value, true)
  125. assert.equal(h.acting.value, false)
  126. assert.equal(h.reloads(), 0)
  127. assert.deepEqual(h.events, [])
  128. })
  129. }
  130. for (const email of ['', null, undefined, 'a@example.com', 'first.last+tag@sub.example.co.uk']) {
  131. test(`contact ${edit ? 'edit' : 'create'} accepts optional email ${email} and free-form telephone`, async () => {
  132. const h = contactHarness(edit, { email })
  133. await h.save()
  134. assert.deepEqual(h.routes, [edit ? 'update' : 'create'])
  135. assert.equal(h.payloads[0].telephone, '前台转 123 / 工作日')
  136. assert.equal(h.payloads[0].email, email ?? '')
  137. assert.equal(h.payloads[0].customerId, 'customer-1')
  138. assert.equal(h.payloads[0].isKey, 1)
  139. assert.equal(h.payloads[0].id, edit ? 'contact-1' : undefined)
  140. assert.equal(h.editing.value, false)
  141. assert.equal(h.reloads(), 1)
  142. assert.deepEqual(h.events, ['changed'])
  143. })
  144. }
  145. }
  146. function customerHarness(edit = false, patch: model.Row = {}, actions: model.Row = {}) {
  147. const calls: string[] = []
  148. const payloads: model.Row[] = []
  149. const navigations: string[] = []
  150. const bindings = {
  151. ...model,
  152. ...feedback(),
  153. props: { embedded: false },
  154. saving: ref(false),
  155. error: ref(''),
  156. uploads: new Map(),
  157. id: ref(edit ? 'customer-1' : ''),
  158. state: { customerId: '' },
  159. invoice: ref<model.Row>({}),
  160. form: ref(
  161. model.customerForm({
  162. ...(edit ? { id: 'customer-1' } : {}),
  163. customerName: '测试客户',
  164. shortName: '测试',
  165. customerType: 'type',
  166. source: 'source',
  167. parkArea: '新园区',
  168. province: '省',
  169. city: '市',
  170. district: '区',
  171. mobile: '',
  172. email: '',
  173. telephone: '办公总机 / 分机 123',
  174. remark: '保留草稿',
  175. ...patch
  176. })
  177. ),
  178. dict: {
  179. getOptions: () => [],
  180. ensure: async () => {
  181. calls.push('ensure')
  182. return { value: '新园区' }
  183. }
  184. },
  185. showCrmConfirmDialog: async () => {
  186. calls.push('confirm')
  187. },
  188. fetchCheckDuplicate: async (data: model.Row) => {
  189. calls.push('duplicate')
  190. return actions.duplicate ? actions.duplicate(data) : { isDuplicate: false }
  191. },
  192. fetchCreateCustomer: async (data: model.Row) => {
  193. calls.push('create')
  194. payloads.push(data)
  195. if (actions.write) await actions.write()
  196. return { id: 'created-customer' }
  197. },
  198. fetchUpdateCustomer: async (data: model.Row) => {
  199. calls.push('update')
  200. payloads.push(data)
  201. if (actions.write) await actions.write()
  202. },
  203. addInvoiceInfo: async (data: model.Row) => {
  204. calls.push('invoice')
  205. payloads.push(data)
  206. if (actions.invoice) await actions.invoice()
  207. },
  208. showSuccessToast: () => {},
  209. router: {
  210. replace: async (path: string) => {
  211. navigations.push(path)
  212. }
  213. }
  214. }
  215. return { ...bindings, ...execute('form.vue', ['save'], bindings), calls, payloads, navigations }
  216. }
  217. test('embedded customer creation and editing emit success without leaving the quote', async () => {
  218. for (const edit of [false, true]) {
  219. const h = customerHarness(edit)
  220. h.props.embedded = true
  221. await h.save()
  222. assert.equal(h.error.value, '')
  223. assert.deepEqual(h.events, ['saved'])
  224. assert.deepEqual(h.navigations, [])
  225. assert.ok(h.calls.includes(edit ? 'update' : 'create'))
  226. }
  227. })
  228. test('production customer and contact email validation agrees with the Web async-validator public API', async () => {
  229. const require = createRequire(import.meta.url)
  230. const requireFromElement = createRequire(require.resolve('element-plus/package.json'))
  231. const Schema = requireFromElement('async-validator').default
  232. const schema = new Schema({ email: { type: 'email' } })
  233. for (const email of [
  234. '',
  235. null,
  236. undefined,
  237. 'bad',
  238. 'a@b',
  239. 'a@example.com',
  240. 'first.last+tag@sub.example.co.uk',
  241. 'a..b@example.com',
  242. '.a@example.com',
  243. 'a@example.c',
  244. ' a@example.com ',
  245. ' ',
  246. '"quoted name"@example.com',
  247. 'user@[192.168.1.1]',
  248. '名字@例子.中国',
  249. 'a@ex-ample.com',
  250. 'a@foo_bar.com',
  251. 'a@domain.123',
  252. 123,
  253. 'a'.repeat(307) + '@example.com',
  254. 'a'.repeat(309) + '@example.com'
  255. ]) {
  256. const valid = await schema.validate({ email }, { suppressWarning: true }).then(
  257. () => true,
  258. () => false
  259. )
  260. for (const h of [contactHarness(false, { email }), customerHarness(false, { email })]) {
  261. await h.save()
  262. assert.equal(h.payloads.length, valid ? 1 : 0, `email=${String(email)}`)
  263. if (!valid) assert.match(h.notices.at(-1) || '', /邮箱/)
  264. }
  265. }
  266. })
  267. for (const edit of [false, true]) {
  268. const invalid = [
  269. ...model.customerGroups
  270. .flatMap((g) => g.fields)
  271. .filter((f) => f.required)
  272. .map((f) => [f.key, ' ', f.label]),
  273. ['mobile', '123', '手机号'],
  274. ['email', 'bad', '邮箱'],
  275. ['email', 'a@b', '邮箱']
  276. ]
  277. for (const [key, value, label] of invalid) {
  278. test(`customer ${edit ? 'edit' : 'create'} rejects ${key} before dictionary, duplicate check or write`, async () => {
  279. const h = customerHarness(edit, { [key]: value })
  280. const before = { ...h.form.value }
  281. await h.save()
  282. assert.deepEqual(h.calls, [])
  283. assert.match(h.error.value, new RegExp(label))
  284. assert.equal(h.notices.at(-1), h.error.value)
  285. assert.deepEqual(h.form.value, before)
  286. assert.equal(h.saving.value, false)
  287. assert.deepEqual(h.navigations, [])
  288. })
  289. }
  290. for (const patch of [
  291. { mobile: '', email: '' },
  292. { mobile: null, email: undefined },
  293. { mobile: '19900138000', email: 'a@example.com' }
  294. ]) {
  295. test(`customer ${edit ? 'edit' : 'create'} accepts optional contact details ${JSON.stringify(patch)}`, async () => {
  296. const h = customerHarness(edit, patch)
  297. await h.save()
  298. assert.deepEqual(h.calls, ['confirm', 'ensure', 'duplicate', edit ? 'update' : 'create'])
  299. assert.equal(h.payloads[0].telephone, '办公总机 / 分机 123')
  300. assert.equal(h.navigations.length, 1)
  301. assert.equal(h.error.value, '')
  302. })
  303. }
  304. }
  305. test('customer invoice failure retries only the invoice, even if the locked customer data is now invalid', async () => {
  306. let fail = true
  307. const h = customerHarness(
  308. false,
  309. {},
  310. {
  311. invoice: async () => {
  312. if (fail) throw new Error('开票保存失败')
  313. }
  314. }
  315. )
  316. h.invoice.value = { companyName: '测试单位', bankAccount: '自由账号' }
  317. await h.save()
  318. assert.equal(h.state.customerId, 'created-customer')
  319. assert.equal(h.error.value, '开票保存失败')
  320. assert.equal(h.notices.at(-1), h.error.value)
  321. assert.deepEqual(h.navigations, [])
  322. h.form.value.shortName = ''
  323. h.form.value.email = 'bad'
  324. fail = false
  325. await h.save()
  326. assert.deepEqual(h.calls, ['confirm', 'ensure', 'duplicate', 'create', 'invoice', 'invoice'])
  327. assert.equal(h.payloads.at(-1)?.customerId, 'created-customer')
  328. assert.equal(h.payloads.at(-1)?.isDefault, '1')
  329. assert.equal(h.navigations.length, 1)
  330. })
  331. test('customer duplicate detection preserves the draft and reports a visible reason', async () => {
  332. const h = customerHarness(false, {}, { duplicate: async () => ({ isDuplicate: true }) })
  333. const before = { ...h.form.value }
  334. await h.save()
  335. assert.deepEqual(h.calls, ['confirm', 'ensure', 'duplicate'])
  336. assert.equal(h.payloads.length, 0)
  337. assert.match(h.error.value, /客户名称已存在/)
  338. assert.equal(h.notices.at(-1), h.error.value)
  339. assert.deepEqual(h.form.value, before)
  340. assert.equal(h.saving.value, false)
  341. })
  342. test('a failed duplicate check performs no customer write and permits retry', async () => {
  343. let fail = true
  344. const h = customerHarness(
  345. false,
  346. {},
  347. {
  348. duplicate: async () => {
  349. if (fail) throw new Error('查重失败,请重试')
  350. return { isDuplicate: false }
  351. }
  352. }
  353. )
  354. const before = { ...h.form.value }
  355. await h.save()
  356. assert.equal(h.payloads.length, 0)
  357. assert.equal(h.error.value, '查重失败,请重试')
  358. assert.equal(h.notices.at(-1), h.error.value)
  359. assert.deepEqual(h.form.value, before)
  360. assert.equal(h.saving.value, false)
  361. fail = false
  362. await h.save()
  363. assert.equal(h.payloads.length, 1)
  364. assert.equal(h.navigations.length, 1)
  365. })
  366. test('optional invoice company name is still validated before both initial save and invoice retry', async () => {
  367. for (const customerId of ['', 'created-customer']) {
  368. const h = customerHarness()
  369. h.state.customerId = customerId
  370. h.invoice.value = { companyName: ' ', bankAccount: '已有账号' }
  371. await h.save()
  372. assert.deepEqual(h.calls, [])
  373. assert.match(h.error.value, /单位名称不能为空/)
  374. assert.equal(h.notices.at(-1), h.error.value)
  375. assert.equal(h.state.customerId, customerId)
  376. assert.equal(h.invoice.value.bankAccount, '已有账号')
  377. }
  378. })
  379. test('customer attachment upload blocks all requests with visible feedback', async () => {
  380. const h = customerHarness()
  381. h.uploads.set('businessLicenseImages', { hasUploading: () => true })
  382. await h.save()
  383. assert.deepEqual(h.calls, [])
  384. assert.equal(h.error.value, '附件上传中,请稍候')
  385. assert.equal(h.notices.at(-1), h.error.value)
  386. })
  387. function opportunityHarness(modeName: string, patch: model.Row = {}, write = async () => {}) {
  388. const payloads: model.Row[] = []
  389. const routes: string[] = []
  390. const capture = (route: string) => async (data: model.Row) => {
  391. routes.push(route)
  392. payloads.push(data)
  393. await write()
  394. }
  395. const bindings = {
  396. ...model,
  397. ...feedback(),
  398. computed,
  399. ref,
  400. mode: ref(modeName),
  401. busy: ref(false),
  402. error: ref(''),
  403. upload: ref(null),
  404. customers: ref([]),
  405. form: ref({
  406. opportunityName: '测试商机',
  407. customerId: 'customer-1',
  408. stage: 'quote',
  409. stagePercent: 60,
  410. winRate: 65,
  411. ownerUserId: 'owner-1',
  412. changeReason: '推进原因',
  413. followType: 'phone',
  414. followTime: '2026-10-22T09:30',
  415. followContent: '联系内容',
  416. nextTime: '',
  417. nextPlan: '下次计划',
  418. attachments: 'fixture.pdf',
  419. remark: '保留备注',
  420. createTime: '只读字段',
  421. status: 'following',
  422. contactId: '不应覆盖关联联系人',
  423. amount: 999,
  424. ...patch
  425. }),
  426. dictOptions: () => [],
  427. OPPORTUNITY_TYPE_FALLBACK: {},
  428. OPPORTUNITY_SOURCE_FALLBACK: {},
  429. props: {
  430. opportunity: { id: 'opportunity-1', stage: 'initial', contactId: 'contact-1', amount: 100 }
  431. },
  432. changeStage: capture('stage'),
  433. updateOpportunity: capture('edit'),
  434. addOpportunityFollow: capture('follow-create'),
  435. updateOpportunityFollow: capture('follow-edit')
  436. }
  437. return {
  438. ...bindings,
  439. ...execute('OpportunityActions.vue', ['progress', 'fields', 'save'], bindings),
  440. payloads,
  441. routes
  442. }
  443. }
  444. for (const mode of ['edit', 'stage']) {
  445. for (const key of ['stagePercent', 'winRate']) {
  446. for (const value of [-1, 101, NaN, Infinity, -Infinity, 'not-a-number']) {
  447. test(`opportunity ${mode} rejects ${key}=${value} at the save entry`, async () => {
  448. const h = opportunityHarness(mode, { [key]: value })
  449. const before = { ...h.form.value }
  450. await h.save()
  451. assert.equal(h.payloads.length, 0)
  452. assert.match(h.error.value, key === 'winRate' ? /赢单率/ : /阶段进度/)
  453. assert.equal(h.notices.at(-1), h.error.value)
  454. assert.deepEqual(h.form.value, before)
  455. assert.equal(h.mode.value, mode)
  456. assert.equal(h.busy.value, false)
  457. })
  458. }
  459. }
  460. for (const value of [0, 7, 100, '0', '100']) {
  461. test(`opportunity ${mode} accepts ${value} without a multiple-of-five restriction`, async () => {
  462. const h = opportunityHarness(mode, { stagePercent: value, winRate: value })
  463. await h.save()
  464. assert.deepEqual(h.routes, [mode])
  465. assert.equal(h.payloads[0].stagePercent, Number(value))
  466. assert.equal(h.payloads[0].winRate, Number(value))
  467. assert.equal(h.mode.value, '')
  468. })
  469. }
  470. }
  471. for (const [mode, input, expected] of [
  472. ['edit', 12.345, 12.35],
  473. ['edit', 1.005, 1.01],
  474. ['edit', 4.015, 4.02],
  475. ['edit', 99.995, 100],
  476. ['edit', 12.3449, 12.34],
  477. ['stage', 7.5, 8],
  478. ['stage', 7.4, 7]
  479. ] as const) {
  480. test(`opportunity ${mode} follows Web input precision for ${input}`, async () => {
  481. const h = opportunityHarness(mode, { stagePercent: input, winRate: input })
  482. await h.save()
  483. assert.equal(h.payloads[0].winRate, expected)
  484. assert.equal(h.payloads[0].stagePercent, mode === 'edit' ? input : expected)
  485. assert.equal(h.form.value.winRate, input)
  486. })
  487. }
  488. test('opportunity edit retains hidden writable associations and excludes readonly fields', async () => {
  489. const h = opportunityHarness('edit')
  490. await h.save()
  491. assert.equal(h.payloads[0].contactId, 'contact-1')
  492. assert.equal(h.payloads[0].amount, 100)
  493. assert.equal(h.payloads[0].id, 'opportunity-1')
  494. assert.equal(h.payloads[0].remark, '保留备注')
  495. assert.equal(Object.hasOwn(h.payloads[0], 'status'), false)
  496. assert.equal(Object.hasOwn(h.payloads[0], 'createTime'), false)
  497. })
  498. test('opportunity stage rejects unchanged stage and preserves the draft', async () => {
  499. const h = opportunityHarness('stage', { stage: 'initial' })
  500. await h.save()
  501. assert.equal(h.payloads.length, 0)
  502. assert.match(h.error.value, /相同/)
  503. assert.equal(h.notices.at(-1), h.error.value)
  504. assert.equal(h.mode.value, 'stage')
  505. })
  506. test('opportunity stage keeps its narrow payload', async () => {
  507. const h = opportunityHarness('stage')
  508. await h.save()
  509. assert.deepEqual(h.payloads[0], {
  510. id: 'opportunity-1',
  511. stage: 'quote',
  512. stagePercent: 60,
  513. winRate: 65,
  514. changeReason: '推进原因',
  515. remark: '保留备注'
  516. })
  517. })
  518. for (const mode of ['edit', 'stage', 'follow']) {
  519. test(`opportunity ${mode} retains all required-field guards with field-specific feedback`, async () => {
  520. const original = opportunityHarness(mode)
  521. for (const field of original.fields.value.filter((f: model.Field) => f.required)) {
  522. const h = opportunityHarness(mode, { [field.key]: ' ' })
  523. await h.save()
  524. assert.equal(h.payloads.length, 0)
  525. assert.ok(h.error.value.includes(field.label), h.error.value)
  526. assert.equal(h.notices.at(-1), h.error.value)
  527. }
  528. })
  529. }
  530. test('opportunity name minimum length remains enforced', async () => {
  531. const h = opportunityHarness('edit', { opportunityName: '单' })
  532. await h.save()
  533. assert.equal(h.payloads.length, 0)
  534. assert.match(h.error.value, /至少两个/)
  535. })
  536. for (const edit of [false, true]) {
  537. test(`opportunity follow ${edit ? 'edit' : 'create'} retains its payload and optional next date`, async () => {
  538. const h = opportunityHarness('follow', edit ? { id: 'follow-1' } : {})
  539. await h.save()
  540. assert.deepEqual(h.routes, [edit ? 'follow-edit' : 'follow-create'])
  541. assert.equal(h.payloads[0].opportunityId, 'opportunity-1')
  542. assert.equal(h.payloads[0].followTime, '2026-10-22 09:30:00')
  543. assert.equal(h.payloads[0].nextTime, '')
  544. assert.equal(h.payloads[0].nextContent, '下次计划')
  545. assert.equal(h.payloads[0].attachments, 'fixture.pdf')
  546. })
  547. }
  548. const lifecycleFactories = {
  549. contact: (write: () => Promise<void>) => {
  550. const h = contactHarness(false, {}, write)
  551. return { ...h, pending: h.acting, error: h.saveError, opened: () => h.editing.value }
  552. },
  553. customer: (write: () => Promise<void>) => {
  554. const h = customerHarness(false, {}, { write })
  555. return { ...h, pending: h.saving, opened: () => h.navigations.length === 0 }
  556. },
  557. opportunity: (write: () => Promise<void>) => {
  558. const h = opportunityHarness('edit', {}, write)
  559. return { ...h, pending: h.busy, opened: () => h.mode.value === 'edit' }
  560. }
  561. }
  562. for (const [name, factory] of Object.entries(lifecycleFactories)) {
  563. test(`${name} API failure retains draft and supports retry`, async () => {
  564. let fail = true
  565. const h = factory(async () => {
  566. if (fail) throw new Error('保存失败测试')
  567. })
  568. const before = { ...h.form.value }
  569. await h.save()
  570. assert.equal(h.error.value, '保存失败测试')
  571. assert.equal(h.notices.at(-1), h.error.value)
  572. assert.equal(h.opened(), true)
  573. assert.equal(h.pending.value, false)
  574. assert.deepEqual(h.form.value, before)
  575. fail = false
  576. await h.save()
  577. assert.equal(h.payloads.length, 2)
  578. assert.equal(h.error.value, '')
  579. })
  580. test(`${name} pending save blocks a second submission`, async () => {
  581. let resolve!: () => void
  582. const h = factory(
  583. () =>
  584. new Promise<void>((done) => {
  585. resolve = done
  586. })
  587. )
  588. const pending = h.save()
  589. await h.save()
  590. while (!resolve) await Promise.resolve()
  591. assert.equal(h.pending.value, true)
  592. assert.equal(h.payloads.length, 1)
  593. resolve()
  594. await pending
  595. assert.equal(h.pending.value, false)
  596. })
  597. }
  598. test('number field bounds reach Vant and all affected SFCs compile', () => {
  599. const source = readFileSync(new URL('CustomerFields.vue', base), 'utf8')
  600. assert.match(source, /:min="field.minValue"/)
  601. assert.match(source, /:max="field.maxValue"/)
  602. for (const file of [
  603. 'CustomerFields.vue',
  604. 'ContactPanel.vue',
  605. 'form.vue',
  606. 'FollowPanel.vue',
  607. 'OpportunityActions.vue'
  608. ]) {
  609. const { descriptor, errors } = parse(readFileSync(new URL(file, base), 'utf8'))
  610. assert.deepEqual(errors, [])
  611. const script = compileScript(descriptor, { id: file })
  612. const template = compileTemplate({
  613. id: file,
  614. filename: file,
  615. source: descriptor.template!.content,
  616. compilerOptions: { bindingMetadata: script.bindings }
  617. })
  618. assert.deepEqual(template.errors, [])
  619. }
  620. })