mobile-contract-review.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447
  1. import assert from 'node:assert/strict'
  2. import { readFileSync } from 'node:fs'
  3. import test from 'node:test'
  4. import { computed, ref, watch, nextTick, effectScope } from 'vue'
  5. import { parse } from '@vue/compiler-sfc'
  6. import ts from 'typescript'
  7. import * as brand from '../src/utils/crm-product-brand'
  8. import * as support from '../src/views/m/crm/contract/mobileFormState'
  9. const parentPath = new URL(
  10. '../src/views/crm/contract/components/ContractFormDialog.vue',
  11. import.meta.url
  12. )
  13. const parent = parse(readFileSync(parentPath, 'utf8')).descriptor
  14. const ast = ts.createSourceFile(
  15. 'parent.ts',
  16. parent.scriptSetup!.content,
  17. ts.ScriptTarget.Latest,
  18. true
  19. )
  20. function select(name: string) {
  21. const node = ast.statements.find(
  22. (node) =>
  23. (ts.isFunctionDeclaration(node) && node.name?.text === name) ||
  24. (ts.isVariableStatement(node) &&
  25. node.declarationList.declarations.some((d) => d.name.getText(ast) === name))
  26. )
  27. assert.ok(node, `Production declaration ${name} exists`)
  28. return node.getText(ast)
  29. }
  30. function evaluate(names: string[], bindings: Record<string, any>, extra = '') {
  31. const code = ts.transpile(names.map(select).join('\n') + '\n' + extra, {
  32. target: ts.ScriptTarget.ESNext
  33. })
  34. return new Function(...Object.keys(bindings), code + ';return {' + names.join(',') + '}')(
  35. ...Object.values(bindings)
  36. )
  37. }
  38. function metaHarness(fetchProductDetail: any) {
  39. const bindings: any = {
  40. ...brand,
  41. ...support,
  42. isPageVariant: ref(true),
  43. fetchProductDetail,
  44. console: { warn: () => {} },
  45. mobileGeneration: 1,
  46. assertMobileCurrent: () => {},
  47. mobileProductMetaError: ref(''),
  48. mobileProductMetaRetry: ref(null),
  49. mobileProductMetaCanDiscard: ref(false),
  50. form: ref({ products: [] }),
  51. calculateProductAmount: () => {},
  52. calculateTotalAmount: () => {}
  53. }
  54. return { ...bindings, ...evaluate(['enrichContractProductBrandMeta'], bindings) }
  55. }
  56. test('mobile metadata failure rejects initialization and preserves the saved required-brand snapshot', async () => {
  57. const h = metaHarness(async () => {
  58. throw new Error('offline')
  59. })
  60. const rows = [
  61. {
  62. productId: 'p',
  63. productName: '甲醇',
  64. brandMode: 'required',
  65. brandId: 'brand1',
  66. brandName: '指定品牌',
  67. brandOptions: []
  68. }
  69. ]
  70. await assert.rejects(h.enrichContractProductBrandMeta(rows), /offline|产品/)
  71. assert.deepEqual(rows[0], {
  72. productId: 'p',
  73. productName: '甲醇',
  74. brandMode: 'required',
  75. brandId: 'brand1',
  76. brandName: '指定品牌',
  77. brandOptions: []
  78. })
  79. assert.ok(h.mobileProductMetaError.value)
  80. assert.equal(typeof h.mobileProductMetaRetry.value, 'function')
  81. })
  82. test('mobile metadata updates are atomic when another line fails', async () => {
  83. const rows = [
  84. {
  85. productId: 'p1',
  86. productName: '原名称',
  87. brandMode: 'required',
  88. brandId: 'b',
  89. brandName: '原品牌',
  90. brandOptions: []
  91. },
  92. { productId: 'p2', productName: '二' }
  93. ]
  94. const before = structuredClone(rows)
  95. const h = metaHarness(async ({ id }: any) => {
  96. if (id === 'p2') throw new Error('offline')
  97. return { data: { id, productName: '新名称', aliases: [], brands: [] } }
  98. })
  99. await assert.rejects(h.enrichContractProductBrandMeta(rows))
  100. assert.deepEqual(rows, before)
  101. })
  102. test('money fields reject negative, non-finite and overprecision values, accepting zero and optional blanks', () => {
  103. assert.equal(typeof support.validateMobileContractMoney, 'function')
  104. for (const key of ['amount', 'freight', 'barrelDeposit']) {
  105. assert.ok(support.validateMobileContractMoney({ [key]: -1 }))
  106. assert.ok(support.validateMobileContractMoney({ [key]: '1.234' }))
  107. assert.ok(support.validateMobileContractMoney({ [key]: 'NaN' }))
  108. assert.equal(support.validateMobileContractMoney({ [key]: '0.29' }), '')
  109. assert.equal(support.validateMobileContractMoney({ [key]: 0 }), '')
  110. assert.equal(support.validateMobileContractMoney({ [key]: '' }), '')
  111. }
  112. })
  113. test('new mobile contracts default to the local calendar date while desktop retains its original date', async () => {
  114. const previous = process.env.TZ
  115. process.env.TZ = 'Asia/Shanghai'
  116. try {
  117. class MorningDate extends Date {
  118. constructor() {
  119. super('2026-10-20T23:00:00Z')
  120. }
  121. }
  122. for (const page of [true, false]) {
  123. const form = ref<any>({})
  124. const bindings = {
  125. ...support,
  126. isPageVariant: ref(page),
  127. Date: MorningDate,
  128. form,
  129. resetForm: () => {},
  130. handleSignDateChange: () => {},
  131. applyDefaultOwnerUser: () => {},
  132. supplierList: ref([])
  133. }
  134. const { initNewContractForm } = evaluate(['initNewContractForm'], bindings)
  135. await initNewContractForm()
  136. assert.equal(form.value.signDate, page ? '2026-10-21' : '2026-10-20')
  137. }
  138. } finally {
  139. if (previous === undefined) delete process.env.TZ
  140. else process.env.TZ = previous
  141. }
  142. })
  143. test('mobile selected values revalidate registered Element fields after model updates', async () => {
  144. const fields: string[][] = []
  145. const form = ref<any>({ customerId: '', ownerUserId: '', settlementMethod: '' })
  146. const expression = ast.statements.find(
  147. (node) =>
  148. ts.isExpressionStatement(node) &&
  149. node.getText(ast).startsWith('watch(') &&
  150. node.getText(ast).includes('mobileValidationFields')
  151. )
  152. assert.ok(expression, 'mobile selection watcher exists')
  153. const bindings = {
  154. ref,
  155. computed,
  156. watch,
  157. nextTick,
  158. isPageVariant: ref(true),
  159. mobileReady: ref(true),
  160. form,
  161. formRef: ref({ validateField: async (keys: string[]) => fields.push(keys) })
  162. }
  163. const scope = effectScope()
  164. scope.run(() =>
  165. evaluate(
  166. ['mobileValidationFields', 'syncMobileFieldValidation'],
  167. bindings,
  168. expression!.getText(ast)
  169. )
  170. )
  171. form.value.customerId = 'c'
  172. form.value.ownerUserId = 'u'
  173. form.value.settlementMethod = 'cash'
  174. await nextTick()
  175. await nextTick()
  176. assert.ok(fields.flat().includes('customerId'))
  177. assert.ok(fields.flat().includes('ownerUserId'))
  178. assert.ok(fields.flat().includes('settlementMethod'))
  179. scope.stop()
  180. })
  181. test('nonempty contact picker exposes the quick-create action', () => {
  182. const text = readFileSync(
  183. new URL('../src/views/m/crm/contract/MobileContractForm.vue', import.meta.url),
  184. 'utf8'
  185. )
  186. const tree = ts.createSourceFile(
  187. 'mobile.ts',
  188. parse(text).descriptor.scriptSetup!.content,
  189. ts.ScriptTarget.Latest,
  190. true
  191. )
  192. const node = tree.statements.find(
  193. (n) => ts.isFunctionDeclaration(n) && n.name?.text === 'openContacts'
  194. )!
  195. assert.match(node.getText(tree), /actionText:\s*'新增联系人'/)
  196. const template = parse(text).descriptor.template!.content
  197. assert.match(template, /:action-text="picker.actionText"/)
  198. })
  199. test('selected multi-pick removal is a labelled 44px button', () => {
  200. const text = readFileSync(
  201. new URL('../src/views/m/crm/contract/ContractPickerSheet.vue', import.meta.url),
  202. 'utf8'
  203. )
  204. assert.match(text, /<button[\s\S]*?class="contract-picker__chip"[\s\S]*?:aria-label=/)
  205. assert.match(text, /\.contract-picker__chip\s*\{[^}]*min-height:\s*44px/s)
  206. })
  207. test('mobile primary buttons define their local background tokens instead of inheriting Vant blue', () => {
  208. for (const name of [
  209. 'MobileContractForm',
  210. 'ContractPickerSheet',
  211. 'ContractSubmitSheet',
  212. 'ContractAuxiliarySheets'
  213. ]) {
  214. const text = readFileSync(
  215. new URL(`../src/views/m/crm/contract/${name}.vue`, import.meta.url),
  216. 'utf8'
  217. )
  218. assert.match(text, /--van-button-primary-background:\s*#0a9f6d/)
  219. assert.match(text, /--van-button-primary-border-color:\s*#0a9f6d/)
  220. }
  221. })
  222. test('metadata retry retains historical required brands through the real submit normalizer', async () => {
  223. let fail = true
  224. const h = metaHarness(async ({ id }: any) => {
  225. if (fail) throw new Error('offline')
  226. return { data: { id, productName: '甲醇', brands: [], aliases: [] } }
  227. })
  228. const rows = [
  229. {
  230. productId: 'p',
  231. productName: '甲醇',
  232. brandMode: 'required',
  233. brandId: 'brand1',
  234. brandName: '指定品牌',
  235. brandOptions: []
  236. }
  237. ]
  238. await assert.rejects(h.enrichContractProductBrandMeta(rows))
  239. fail = false
  240. await h.mobileProductMetaRetry.value()
  241. brand.prepareLineAliasAndBrandForSubmit(rows[0])
  242. assert.equal(rows[0].brandMode, 'required')
  243. assert.equal(rows[0].brandId, 'brand1')
  244. assert.equal(rows[0].brandName, '指定品牌')
  245. assert.equal(h.mobileProductMetaError.value, '')
  246. assert.equal(h.mobileProductMetaRetry.value, null)
  247. })
  248. test('new product selection adds nothing on metadata failure and a retry adds complete rows once', async () => {
  249. let fail = true
  250. const added: any[] = []
  251. const h: any = {
  252. ...support,
  253. mobileGeneration: 1,
  254. form: ref({ supplierId: 's' }),
  255. mobileProductMetaError: ref(''),
  256. mobileProductMetaRetry: ref(null),
  257. mobileProductMetaCanDiscard: ref(false),
  258. assertMobileCurrent: () => {},
  259. fetchProductDetail: async ({ id }: any) => {
  260. if (fail) throw new Error('offline')
  261. return { data: { id, productName: '甲醇', brands: [{ id: 'b', brandName: '品牌' }] } }
  262. },
  263. handleProductSelected: (products: any[]) => added.push(...products)
  264. }
  265. const { addMobileProducts } = evaluate(['addMobileProducts'], h)
  266. await assert.rejects(addMobileProducts([{ id: 'p', productName: '列表名称' }]))
  267. assert.equal(added.length, 0)
  268. assert.ok(h.mobileProductMetaError.value)
  269. assert.equal(h.mobileProductMetaCanDiscard.value, true)
  270. fail = false
  271. await h.mobileProductMetaRetry.value()
  272. assert.equal(added.length, 1)
  273. assert.equal(added[0].brands[0].id, 'b')
  274. assert.equal(h.mobileProductMetaError.value, '')
  275. assert.equal(h.mobileProductMetaRetry.value, null)
  276. assert.equal(h.mobileProductMetaCanDiscard.value, false)
  277. })
  278. test('superseded product metadata requests cannot mutate rows or overwrite current error state', async () => {
  279. let finish: any
  280. let current = true
  281. const row = {
  282. productId: 'p',
  283. productName: '旧名称',
  284. brandMode: 'required',
  285. brandId: 'b',
  286. brandName: '旧品牌',
  287. brandOptions: []
  288. }
  289. const original = structuredClone(row)
  290. const h = metaHarness(
  291. () =>
  292. new Promise((resolve) => {
  293. finish = resolve
  294. })
  295. )
  296. h.assertMobileCurrent = () => {
  297. if (!current) throw new Error('stale')
  298. }
  299. const { enrichContractProductBrandMeta } = evaluate(['enrichContractProductBrandMeta'], h)
  300. const request = enrichContractProductBrandMeta([row])
  301. current = false
  302. h.mobileProductMetaError.value = 'current error'
  303. finish({ data: { id: 'p', productName: '新名称', brands: [], aliases: [] } })
  304. await assert.rejects(request, /stale/)
  305. assert.deepEqual(row, original)
  306. assert.equal(h.mobileProductMetaError.value, 'current error')
  307. })
  308. test('multiple selections of one product retain separate lines with one metadata lookup', async () => {
  309. let calls = 0
  310. const details = await support.loadMobileContractProductDetails(
  311. [{ id: 'p' }, { id: 'p' }],
  312. async () => {
  313. calls++
  314. return { data: { id: 'p', productName: '甲醇' } }
  315. }
  316. )
  317. assert.equal(calls, 1)
  318. assert.equal(details.length, 2)
  319. })
  320. test('metadata failure keeps actual mobile initialization not ready until retry succeeds', async () => {
  321. let fail = true
  322. const h: any = metaHarness(async ({ id }: any) => {
  323. if (fail) throw new Error('产品服务暂不可用')
  324. return { data: { id, productName: '甲醇', brands: [] } }
  325. })
  326. const rows = [
  327. {
  328. productId: 'p',
  329. productName: '甲醇',
  330. brandMode: 'required',
  331. brandId: 'brand1',
  332. brandName: '指定品牌',
  333. brandOptions: []
  334. }
  335. ]
  336. Object.assign(h, {
  337. mobileReady: ref(false),
  338. mobileError: ref(''),
  339. mobileContactError: ref(''),
  340. formLoading: ref(false),
  341. emit: () => {},
  342. resetForm: () => {},
  343. props: { editId: 'contract', copySource: null },
  344. loadSupplierList: async () => {},
  345. loadUserList: async () => {},
  346. loadContractDetail: async () => h.enrichContractProductBrandMeta(rows)
  347. })
  348. const { initializeMobileForm } = evaluate(['initializeMobileForm'], h)
  349. await initializeMobileForm()
  350. assert.equal(h.mobileReady.value, false)
  351. assert.equal(h.formLoading.value, false)
  352. assert.match(h.mobileError.value, /产品服务/)
  353. assert.equal(rows[0].brandId, 'brand1')
  354. fail = false
  355. await initializeMobileForm()
  356. assert.equal(h.mobileReady.value, true)
  357. assert.equal(h.mobileError.value, '')
  358. assert.equal(h.mobileProductMetaError.value, '')
  359. })
  360. test('desktop metadata keeps its prior tolerant failure path and never sets mobile errors', async () => {
  361. const h = metaHarness(async () => {
  362. throw new Error('offline')
  363. })
  364. h.isPageVariant.value = false
  365. await h.enrichContractProductBrandMeta([{ productId: 'p' }])
  366. assert.equal(h.mobileProductMetaError.value, '')
  367. assert.equal(h.mobileProductMetaRetry.value, null)
  368. })
  369. test('mobile popup selection controls and navigation define local brand tokens', () => {
  370. for (const name of ['ContractSubmitSheet', 'ContractAuxiliarySheets']) {
  371. const source = readFileSync(
  372. new URL(`../src/views/m/crm/contract/${name}.vue`, import.meta.url),
  373. 'utf8'
  374. )
  375. for (const token of ['radio-checked-icon-color', 'nav-bar-text-color', 'nav-bar-icon-color']) {
  376. assert.match(source, new RegExp(`--van-${token}:\\s*#0a9f6d`))
  377. }
  378. if (name === 'ContractAuxiliarySheets')
  379. assert.match(source, /--van-switch-on-background:\s*#0a9f6d/)
  380. }
  381. })
  382. test('form section tracking ignores loading/hidden and unscrolled short pages, but follows real scrolling', () => {
  383. const content = parse(
  384. readFileSync(
  385. new URL('../src/views/m/crm/contract/MobileContractForm.vue', import.meta.url),
  386. 'utf8'
  387. )
  388. ).descriptor.scriptSetup!.content
  389. const tree = ts.createSourceFile('mobile-form.ts', content, ts.ScriptTarget.Latest, true)
  390. const node = tree.statements.find(
  391. (item) => ts.isFunctionDeclaration(item) && item.name?.text === 'syncSection'
  392. )!
  393. const c = { ready: false, loading: true }
  394. const activeSection = ref('base')
  395. const navRef = ref<any>(undefined)
  396. const browser = { innerHeight: 700, scrollY: 0 }
  397. const bounds: Record<string, number> = { base: 100, products: 500, attachments: 1000 }
  398. const doc = {
  399. documentElement: { scrollHeight: 600 },
  400. querySelector: () => null,
  401. getElementById: (id: string) => ({
  402. getBoundingClientRect: () => ({ top: bounds[id.replace('contract-form-', '')] })
  403. })
  404. }
  405. const sync = new Function(
  406. 'c',
  407. 'activeSection',
  408. 'navRef',
  409. 'headerHeight',
  410. 'sections',
  411. 'window',
  412. 'document',
  413. `let scrollLockUntil = 0; ${ts.transpile(node.getText(tree))}; return syncSection`
  414. )(
  415. c,
  416. activeSection,
  417. navRef,
  418. ref(66),
  419. ref([{ id: 'base' }, { id: 'products' }, { id: 'attachments' }]),
  420. browser,
  421. doc
  422. )
  423. sync()
  424. assert.equal(activeSection.value, 'base', 'loading skeleton must not select attachments')
  425. c.ready = true
  426. c.loading = false
  427. navRef.value = { getBoundingClientRect: () => ({ bottom: 90 }), getClientRects: () => [] }
  428. sync()
  429. assert.equal(activeSection.value, 'base', 'hidden form must not select attachments')
  430. navRef.value.getClientRects = () => [{}]
  431. sync()
  432. assert.equal(activeSection.value, 'base', 'an unscrolled page cannot be treated as bottom')
  433. doc.documentElement.scrollHeight = 1800
  434. browser.scrollY = 600
  435. bounds.products = 90
  436. sync()
  437. assert.equal(activeSection.value, 'products')
  438. browser.scrollY = 1100
  439. sync()
  440. assert.equal(activeSection.value, 'attachments', 'real bottom still selects last section')
  441. })