mobile-customer-validation.test.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633
  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. saving: ref(false),
  154. error: ref(''),
  155. uploads: new Map(),
  156. id: ref(edit ? 'customer-1' : ''),
  157. state: { customerId: '' },
  158. invoice: ref<model.Row>({}),
  159. form: ref(
  160. model.customerForm({
  161. ...(edit ? { id: 'customer-1' } : {}),
  162. customerName: '测试客户',
  163. shortName: '测试',
  164. customerType: 'type',
  165. source: 'source',
  166. parkArea: '新园区',
  167. province: '省',
  168. city: '市',
  169. district: '区',
  170. mobile: '',
  171. email: '',
  172. telephone: '办公总机 / 分机 123',
  173. remark: '保留草稿',
  174. ...patch
  175. })
  176. ),
  177. dict: {
  178. getOptions: () => [],
  179. ensure: async () => {
  180. calls.push('ensure')
  181. return { value: '新园区' }
  182. }
  183. },
  184. showCrmConfirmDialog: async () => {
  185. calls.push('confirm')
  186. },
  187. fetchCheckDuplicate: async (data: model.Row) => {
  188. calls.push('duplicate')
  189. return actions.duplicate ? actions.duplicate(data) : { isDuplicate: false }
  190. },
  191. fetchCreateCustomer: async (data: model.Row) => {
  192. calls.push('create')
  193. payloads.push(data)
  194. if (actions.write) await actions.write()
  195. return { id: 'created-customer' }
  196. },
  197. fetchUpdateCustomer: async (data: model.Row) => {
  198. calls.push('update')
  199. payloads.push(data)
  200. if (actions.write) await actions.write()
  201. },
  202. addInvoiceInfo: async (data: model.Row) => {
  203. calls.push('invoice')
  204. payloads.push(data)
  205. if (actions.invoice) await actions.invoice()
  206. },
  207. showSuccessToast: () => {},
  208. router: {
  209. replace: async (path: string) => {
  210. navigations.push(path)
  211. }
  212. }
  213. }
  214. return { ...bindings, ...execute('form.vue', ['save'], bindings), calls, payloads, navigations }
  215. }
  216. test('production customer and contact email validation agrees with the Web async-validator public API', async () => {
  217. const require = createRequire(import.meta.url)
  218. const requireFromElement = createRequire(require.resolve('element-plus/package.json'))
  219. const Schema = requireFromElement('async-validator').default
  220. const schema = new Schema({ email: { type: 'email' } })
  221. for (const email of [
  222. '',
  223. null,
  224. undefined,
  225. 'bad',
  226. 'a@b',
  227. 'a@example.com',
  228. 'first.last+tag@sub.example.co.uk',
  229. 'a..b@example.com',
  230. '.a@example.com',
  231. 'a@example.c',
  232. ' a@example.com ',
  233. ' ',
  234. '"quoted name"@example.com',
  235. 'user@[192.168.1.1]',
  236. '名字@例子.中国',
  237. 'a@ex-ample.com',
  238. 'a@foo_bar.com',
  239. 'a@domain.123',
  240. 123,
  241. 'a'.repeat(307) + '@example.com',
  242. 'a'.repeat(309) + '@example.com'
  243. ]) {
  244. const valid = await schema.validate({ email }, { suppressWarning: true }).then(
  245. () => true,
  246. () => false
  247. )
  248. for (const h of [contactHarness(false, { email }), customerHarness(false, { email })]) {
  249. await h.save()
  250. assert.equal(h.payloads.length, valid ? 1 : 0, `email=${String(email)}`)
  251. if (!valid) assert.match(h.notices.at(-1) || '', /邮箱/)
  252. }
  253. }
  254. })
  255. for (const edit of [false, true]) {
  256. const invalid = [
  257. ...model.customerGroups
  258. .flatMap((g) => g.fields)
  259. .filter((f) => f.required)
  260. .map((f) => [f.key, ' ', f.label]),
  261. ['mobile', '123', '手机号'],
  262. ['email', 'bad', '邮箱'],
  263. ['email', 'a@b', '邮箱']
  264. ]
  265. for (const [key, value, label] of invalid) {
  266. test(`customer ${edit ? 'edit' : 'create'} rejects ${key} before dictionary, duplicate check or write`, async () => {
  267. const h = customerHarness(edit, { [key]: value })
  268. const before = { ...h.form.value }
  269. await h.save()
  270. assert.deepEqual(h.calls, [])
  271. assert.match(h.error.value, new RegExp(label))
  272. assert.equal(h.notices.at(-1), h.error.value)
  273. assert.deepEqual(h.form.value, before)
  274. assert.equal(h.saving.value, false)
  275. assert.deepEqual(h.navigations, [])
  276. })
  277. }
  278. for (const patch of [
  279. { mobile: '', email: '' },
  280. { mobile: null, email: undefined },
  281. { mobile: '19900138000', email: 'a@example.com' }
  282. ]) {
  283. test(`customer ${edit ? 'edit' : 'create'} accepts optional contact details ${JSON.stringify(patch)}`, async () => {
  284. const h = customerHarness(edit, patch)
  285. await h.save()
  286. assert.deepEqual(h.calls, ['confirm', 'ensure', 'duplicate', edit ? 'update' : 'create'])
  287. assert.equal(h.payloads[0].telephone, '办公总机 / 分机 123')
  288. assert.equal(h.navigations.length, 1)
  289. assert.equal(h.error.value, '')
  290. })
  291. }
  292. }
  293. test('customer invoice failure retries only the invoice, even if the locked customer data is now invalid', async () => {
  294. let fail = true
  295. const h = customerHarness(
  296. false,
  297. {},
  298. {
  299. invoice: async () => {
  300. if (fail) throw new Error('开票保存失败')
  301. }
  302. }
  303. )
  304. h.invoice.value = { companyName: '测试单位', bankAccount: '自由账号' }
  305. await h.save()
  306. assert.equal(h.state.customerId, 'created-customer')
  307. assert.equal(h.error.value, '开票保存失败')
  308. assert.equal(h.notices.at(-1), h.error.value)
  309. assert.deepEqual(h.navigations, [])
  310. h.form.value.shortName = ''
  311. h.form.value.email = 'bad'
  312. fail = false
  313. await h.save()
  314. assert.deepEqual(h.calls, ['confirm', 'ensure', 'duplicate', 'create', 'invoice', 'invoice'])
  315. assert.equal(h.payloads.at(-1)?.customerId, 'created-customer')
  316. assert.equal(h.payloads.at(-1)?.isDefault, '1')
  317. assert.equal(h.navigations.length, 1)
  318. })
  319. test('customer duplicate detection preserves the draft and reports a visible reason', async () => {
  320. const h = customerHarness(false, {}, { duplicate: async () => ({ isDuplicate: true }) })
  321. const before = { ...h.form.value }
  322. await h.save()
  323. assert.deepEqual(h.calls, ['confirm', 'ensure', 'duplicate'])
  324. assert.equal(h.payloads.length, 0)
  325. assert.match(h.error.value, /客户名称已存在/)
  326. assert.equal(h.notices.at(-1), h.error.value)
  327. assert.deepEqual(h.form.value, before)
  328. assert.equal(h.saving.value, false)
  329. })
  330. test('a failed duplicate check performs no customer write and permits retry', async () => {
  331. let fail = true
  332. const h = customerHarness(
  333. false,
  334. {},
  335. {
  336. duplicate: async () => {
  337. if (fail) throw new Error('查重失败,请重试')
  338. return { isDuplicate: false }
  339. }
  340. }
  341. )
  342. const before = { ...h.form.value }
  343. await h.save()
  344. assert.equal(h.payloads.length, 0)
  345. assert.equal(h.error.value, '查重失败,请重试')
  346. assert.equal(h.notices.at(-1), h.error.value)
  347. assert.deepEqual(h.form.value, before)
  348. assert.equal(h.saving.value, false)
  349. fail = false
  350. await h.save()
  351. assert.equal(h.payloads.length, 1)
  352. assert.equal(h.navigations.length, 1)
  353. })
  354. test('optional invoice company name is still validated before both initial save and invoice retry', async () => {
  355. for (const customerId of ['', 'created-customer']) {
  356. const h = customerHarness()
  357. h.state.customerId = customerId
  358. h.invoice.value = { companyName: ' ', bankAccount: '已有账号' }
  359. await h.save()
  360. assert.deepEqual(h.calls, [])
  361. assert.match(h.error.value, /单位名称不能为空/)
  362. assert.equal(h.notices.at(-1), h.error.value)
  363. assert.equal(h.state.customerId, customerId)
  364. assert.equal(h.invoice.value.bankAccount, '已有账号')
  365. }
  366. })
  367. test('customer attachment upload blocks all requests with visible feedback', async () => {
  368. const h = customerHarness()
  369. h.uploads.set('businessLicenseImages', { hasUploading: () => true })
  370. await h.save()
  371. assert.deepEqual(h.calls, [])
  372. assert.equal(h.error.value, '附件上传中,请稍候')
  373. assert.equal(h.notices.at(-1), h.error.value)
  374. })
  375. function opportunityHarness(modeName: string, patch: model.Row = {}, write = async () => {}) {
  376. const payloads: model.Row[] = []
  377. const routes: string[] = []
  378. const capture = (route: string) => async (data: model.Row) => {
  379. routes.push(route)
  380. payloads.push(data)
  381. await write()
  382. }
  383. const bindings = {
  384. ...model,
  385. ...feedback(),
  386. computed,
  387. ref,
  388. mode: ref(modeName),
  389. busy: ref(false),
  390. error: ref(''),
  391. upload: ref(null),
  392. customers: ref([]),
  393. form: ref({
  394. opportunityName: '测试商机',
  395. customerId: 'customer-1',
  396. stage: 'quote',
  397. stagePercent: 60,
  398. winRate: 65,
  399. ownerUserId: 'owner-1',
  400. changeReason: '推进原因',
  401. followType: 'phone',
  402. followTime: '2026-10-22T09:30',
  403. followContent: '联系内容',
  404. nextTime: '',
  405. nextPlan: '下次计划',
  406. attachments: 'fixture.pdf',
  407. remark: '保留备注',
  408. createTime: '只读字段',
  409. status: 'following',
  410. contactId: '不应覆盖关联联系人',
  411. amount: 999,
  412. ...patch
  413. }),
  414. dictOptions: () => [],
  415. OPPORTUNITY_TYPE_FALLBACK: {},
  416. OPPORTUNITY_SOURCE_FALLBACK: {},
  417. props: {
  418. opportunity: { id: 'opportunity-1', stage: 'initial', contactId: 'contact-1', amount: 100 }
  419. },
  420. changeStage: capture('stage'),
  421. updateOpportunity: capture('edit'),
  422. addOpportunityFollow: capture('follow-create'),
  423. updateOpportunityFollow: capture('follow-edit')
  424. }
  425. return {
  426. ...bindings,
  427. ...execute('OpportunityActions.vue', ['progress', 'fields', 'save'], bindings),
  428. payloads,
  429. routes
  430. }
  431. }
  432. for (const mode of ['edit', 'stage']) {
  433. for (const key of ['stagePercent', 'winRate']) {
  434. for (const value of [-1, 101, NaN, Infinity, -Infinity, 'not-a-number']) {
  435. test(`opportunity ${mode} rejects ${key}=${value} at the save entry`, async () => {
  436. const h = opportunityHarness(mode, { [key]: value })
  437. const before = { ...h.form.value }
  438. await h.save()
  439. assert.equal(h.payloads.length, 0)
  440. assert.match(h.error.value, key === 'winRate' ? /赢单率/ : /阶段进度/)
  441. assert.equal(h.notices.at(-1), h.error.value)
  442. assert.deepEqual(h.form.value, before)
  443. assert.equal(h.mode.value, mode)
  444. assert.equal(h.busy.value, false)
  445. })
  446. }
  447. }
  448. for (const value of [0, 7, 100, '0', '100']) {
  449. test(`opportunity ${mode} accepts ${value} without a multiple-of-five restriction`, async () => {
  450. const h = opportunityHarness(mode, { stagePercent: value, winRate: value })
  451. await h.save()
  452. assert.deepEqual(h.routes, [mode])
  453. assert.equal(h.payloads[0].stagePercent, Number(value))
  454. assert.equal(h.payloads[0].winRate, Number(value))
  455. assert.equal(h.mode.value, '')
  456. })
  457. }
  458. }
  459. for (const [mode, input, expected] of [
  460. ['edit', 12.345, 12.35],
  461. ['edit', 1.005, 1.01],
  462. ['edit', 4.015, 4.02],
  463. ['edit', 99.995, 100],
  464. ['edit', 12.3449, 12.34],
  465. ['stage', 7.5, 8],
  466. ['stage', 7.4, 7]
  467. ] as const) {
  468. test(`opportunity ${mode} follows Web input precision for ${input}`, async () => {
  469. const h = opportunityHarness(mode, { stagePercent: input, winRate: input })
  470. await h.save()
  471. assert.equal(h.payloads[0].winRate, expected)
  472. assert.equal(h.payloads[0].stagePercent, mode === 'edit' ? input : expected)
  473. assert.equal(h.form.value.winRate, input)
  474. })
  475. }
  476. test('opportunity edit retains hidden writable associations and excludes readonly fields', async () => {
  477. const h = opportunityHarness('edit')
  478. await h.save()
  479. assert.equal(h.payloads[0].contactId, 'contact-1')
  480. assert.equal(h.payloads[0].amount, 100)
  481. assert.equal(h.payloads[0].id, 'opportunity-1')
  482. assert.equal(h.payloads[0].remark, '保留备注')
  483. assert.equal(Object.hasOwn(h.payloads[0], 'status'), false)
  484. assert.equal(Object.hasOwn(h.payloads[0], 'createTime'), false)
  485. })
  486. test('opportunity stage rejects unchanged stage and preserves the draft', async () => {
  487. const h = opportunityHarness('stage', { stage: 'initial' })
  488. await h.save()
  489. assert.equal(h.payloads.length, 0)
  490. assert.match(h.error.value, /相同/)
  491. assert.equal(h.notices.at(-1), h.error.value)
  492. assert.equal(h.mode.value, 'stage')
  493. })
  494. test('opportunity stage keeps its narrow payload', async () => {
  495. const h = opportunityHarness('stage')
  496. await h.save()
  497. assert.deepEqual(h.payloads[0], {
  498. id: 'opportunity-1',
  499. stage: 'quote',
  500. stagePercent: 60,
  501. winRate: 65,
  502. changeReason: '推进原因',
  503. remark: '保留备注'
  504. })
  505. })
  506. for (const mode of ['edit', 'stage', 'follow']) {
  507. test(`opportunity ${mode} retains all required-field guards with field-specific feedback`, async () => {
  508. const original = opportunityHarness(mode)
  509. for (const field of original.fields.value.filter((f: model.Field) => f.required)) {
  510. const h = opportunityHarness(mode, { [field.key]: ' ' })
  511. await h.save()
  512. assert.equal(h.payloads.length, 0)
  513. assert.ok(h.error.value.includes(field.label), h.error.value)
  514. assert.equal(h.notices.at(-1), h.error.value)
  515. }
  516. })
  517. }
  518. test('opportunity name minimum length remains enforced', async () => {
  519. const h = opportunityHarness('edit', { opportunityName: '单' })
  520. await h.save()
  521. assert.equal(h.payloads.length, 0)
  522. assert.match(h.error.value, /至少两个/)
  523. })
  524. for (const edit of [false, true]) {
  525. test(`opportunity follow ${edit ? 'edit' : 'create'} retains its payload and optional next date`, async () => {
  526. const h = opportunityHarness('follow', edit ? { id: 'follow-1' } : {})
  527. await h.save()
  528. assert.deepEqual(h.routes, [edit ? 'follow-edit' : 'follow-create'])
  529. assert.equal(h.payloads[0].opportunityId, 'opportunity-1')
  530. assert.equal(h.payloads[0].followTime, '2026-10-22 09:30:00')
  531. assert.equal(h.payloads[0].nextTime, '')
  532. assert.equal(h.payloads[0].nextContent, '下次计划')
  533. assert.equal(h.payloads[0].attachments, 'fixture.pdf')
  534. })
  535. }
  536. const lifecycleFactories = {
  537. contact: (write: () => Promise<void>) => {
  538. const h = contactHarness(false, {}, write)
  539. return { ...h, pending: h.acting, error: h.saveError, opened: () => h.editing.value }
  540. },
  541. customer: (write: () => Promise<void>) => {
  542. const h = customerHarness(false, {}, { write })
  543. return { ...h, pending: h.saving, opened: () => h.navigations.length === 0 }
  544. },
  545. opportunity: (write: () => Promise<void>) => {
  546. const h = opportunityHarness('edit', {}, write)
  547. return { ...h, pending: h.busy, opened: () => h.mode.value === 'edit' }
  548. }
  549. }
  550. for (const [name, factory] of Object.entries(lifecycleFactories)) {
  551. test(`${name} API failure retains draft and supports retry`, async () => {
  552. let fail = true
  553. const h = factory(async () => {
  554. if (fail) throw new Error('保存失败测试')
  555. })
  556. const before = { ...h.form.value }
  557. await h.save()
  558. assert.equal(h.error.value, '保存失败测试')
  559. assert.equal(h.notices.at(-1), h.error.value)
  560. assert.equal(h.opened(), true)
  561. assert.equal(h.pending.value, false)
  562. assert.deepEqual(h.form.value, before)
  563. fail = false
  564. await h.save()
  565. assert.equal(h.payloads.length, 2)
  566. assert.equal(h.error.value, '')
  567. })
  568. test(`${name} pending save blocks a second submission`, async () => {
  569. let resolve!: () => void
  570. const h = factory(
  571. () =>
  572. new Promise<void>((done) => {
  573. resolve = done
  574. })
  575. )
  576. const pending = h.save()
  577. await h.save()
  578. while (!resolve) await Promise.resolve()
  579. assert.equal(h.pending.value, true)
  580. assert.equal(h.payloads.length, 1)
  581. resolve()
  582. await pending
  583. assert.equal(h.pending.value, false)
  584. })
  585. }
  586. test('number field bounds reach Vant and all affected SFCs compile', () => {
  587. const source = readFileSync(new URL('CustomerFields.vue', base), 'utf8')
  588. assert.match(source, /:min="field.minValue"/)
  589. assert.match(source, /:max="field.maxValue"/)
  590. for (const file of [
  591. 'CustomerFields.vue',
  592. 'ContactPanel.vue',
  593. 'form.vue',
  594. 'FollowPanel.vue',
  595. 'OpportunityActions.vue'
  596. ]) {
  597. const { descriptor, errors } = parse(readFileSync(new URL(file, base), 'utf8'))
  598. assert.deepEqual(errors, [])
  599. const script = compileScript(descriptor, { id: file })
  600. const template = compileTemplate({
  601. id: file,
  602. filename: file,
  603. source: descriptor.template!.content,
  604. compilerOptions: { bindingMetadata: script.bindings }
  605. })
  606. assert.deepEqual(template.errors, [])
  607. }
  608. })