mobile-contract-list-parity.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435
  1. import assert from 'node:assert/strict'
  2. import test from 'node:test'
  3. import { existsSync, readFileSync } from 'node:fs'
  4. import * as Vue from 'vue'
  5. import { parse } from '@vue/compiler-sfc'
  6. import ts from 'typescript'
  7. import * as card from '../src/utils/mp-crm-contract-card'
  8. import { buildContractColumnFilterDefs } from '../src/views/crm/contract/columnFilterDefs'
  9. async function model() {
  10. assert.ok(
  11. existsSync(new URL('../src/views/m/crm/contract/listState.ts', import.meta.url)),
  12. 'contract query model exists'
  13. )
  14. return import('../src/views/m/crm/contract/listState')
  15. }
  16. async function harness(
  17. page: string,
  18. props: any = { mode: 'manage' },
  19. overrides: Record<string, any> = {}
  20. ) {
  21. const file = new URL(`../src/views/m/crm/contract/${page}.vue`, import.meta.url)
  22. assert.ok(existsSync(file), `${page} must exist`)
  23. const source = readFileSync(file, 'utf8')
  24. const ast = ts.createSourceFile(
  25. 'page.ts',
  26. parse(source).descriptor.scriptSetup!.content,
  27. ts.ScriptTarget.Latest,
  28. true
  29. )
  30. const statements = ast.statements.filter((s) => !ts.isImportDeclaration(s))
  31. const names = statements.flatMap((s) =>
  32. ts.isFunctionDeclaration(s) && s.name
  33. ? [s.name.text]
  34. : ts.isVariableStatement(s)
  35. ? s.declarationList.declarations.flatMap((d) =>
  36. ts.isIdentifier(d.name)
  37. ? [d.name.text]
  38. : ts.isObjectBindingPattern(d.name)
  39. ? d.name.elements.map((e) => e.name.getText(ast))
  40. : []
  41. )
  42. : []
  43. )
  44. const bindings: Record<string, any> = { ...Vue }
  45. for (const s of ast.statements)
  46. if (ts.isImportDeclaration(s)) {
  47. if (s.importClause?.name) bindings[s.importClause.name.text] = () => {}
  48. const n = s.importClause?.namedBindings
  49. if (n && ts.isNamedImports(n)) for (const e of n.elements) bindings[e.name.text] = () => {}
  50. }
  51. const requests: any[] = [],
  52. events: any[] = [],
  53. pushes: any[] = [],
  54. facets: any[] = []
  55. const user = Vue.reactive({ accessToken: 'token', info: { id: 'u1' }, getUserInfo: { id: 'u1' } })
  56. Object.assign(
  57. bindings,
  58. card,
  59. existsSync(new URL('../src/views/m/crm/contract/listState.ts', import.meta.url))
  60. ? await model()
  61. : {},
  62. {
  63. buildContractColumnFilterDefs,
  64. defineProps: () => props,
  65. defineEmits:
  66. () =>
  67. (...args: any[]) =>
  68. events.push(args),
  69. route: Vue.reactive({ fullPath: '/m/crm/contract' }),
  70. ElMessage: { error() {}, warning() {} },
  71. useRouter: () => ({ push: (v: any) => pushes.push(v) }),
  72. useUserStore: () => user,
  73. useDictStore: () => ({
  74. getOptions: () => [{ value: 'invalid', label: '作废' }],
  75. getLabel: (_: any, value: any) => value,
  76. getTagType: () => 'info'
  77. }),
  78. useListReturnState: () => ({ restoring: Vue.ref(false), restore: async () => {} }),
  79. useIntersectionObserver: () => {},
  80. useDebounceFn: (fn: any) => fn,
  81. listContract: async (q: any) => {
  82. requests.push(q)
  83. return { rows: [], total: 0 }
  84. },
  85. fetchContractColumnFacetValues: async (q: any) => {
  86. facets.push(q)
  87. return {
  88. list: [
  89. { value: 'A', label: '甲' },
  90. { value: 'B', label: '乙' }
  91. ]
  92. }
  93. },
  94. HttpError: class extends Error {},
  95. ApiStatus: { forbidden: 403 },
  96. onMounted: () => {},
  97. onBeforeUnmount: () => {}
  98. },
  99. overrides
  100. )
  101. for (const key of Object.keys(bindings))
  102. if (!/^[a-zA-Z_$][\w$]*$/.test(key) || key === 'default') delete bindings[key]
  103. const code = ts.transpile(statements.map((s) => s.getText(ast)).join('\n'), {
  104. target: ts.ScriptTarget.ESNext
  105. })
  106. const scope = Vue.effectScope()
  107. const api = scope.run(() =>
  108. new Function(...Object.keys(bindings), `${code}; return {${names.join(',')}}`)(
  109. ...Object.values(bindings)
  110. )
  111. )
  112. return { api, requests, events, pushes, facets, user, source, stop: () => scope.stop() }
  113. }
  114. test('contract default field filters serialize Web parameters and preserve explicit status in both modes', async () => {
  115. const m = await model()
  116. for (const mode of ['manage', 'approve'] as const)
  117. for (const view of ['all', 'submitted', 'approved'] as const) {
  118. const input = {
  119. mode,
  120. view,
  121. keyword: ' 客户 ',
  122. query: {},
  123. currentUserId: 'u1',
  124. pageNum: 2,
  125. pageSize: 20
  126. }
  127. const params = m.buildMobileContractQuery(input)
  128. assert.equal(params.excludeStatus, 'invalid')
  129. assert.equal(params.includeProducts, true)
  130. assert.equal(params.keyword, '客户')
  131. assert.equal(
  132. params.pendingApprovalUserId,
  133. mode === 'approve' && view === 'submitted' ? 'u1' : undefined
  134. )
  135. assert.equal(
  136. params.approvedByUserId,
  137. mode === 'approve' && view === 'approved' ? 'u1' : undefined
  138. )
  139. const explicit = m.buildMobileContractQuery({
  140. ...input,
  141. query: {
  142. statusIn: 'invalid,approved',
  143. contractNoIn: 'C1',
  144. contractNameIn: '合同',
  145. amountOp: 'gte',
  146. amount: '0',
  147. beginTime: '2026-01-01',
  148. signDateEnd: '2026-02-01',
  149. remark: 'hidden'
  150. }
  151. })
  152. assert.equal(explicit.excludeStatus, undefined)
  153. assert.equal(explicit.statusIn, 'invalid,approved')
  154. assert.equal(explicit.contractNoIn, 'C1')
  155. assert.equal(explicit.amount, '0')
  156. assert.equal(explicit.beginTime, '2026-01-01')
  157. assert.equal(explicit.signDateEnd, '2026-02-01')
  158. assert.equal(explicit.remark, undefined)
  159. }
  160. const defs = m.contractFilterDefs(() => [])
  161. assert.equal(defs.length, 11)
  162. const draft = m.createContractFilterDraft(
  163. { contractNoIn: 'C1,C2', freightOp: 'eq', freight: '0' },
  164. defs
  165. )
  166. assert.deepEqual(m.contractFilterQuery(draft, defs), {
  167. contractNoIn: 'C1,C2',
  168. freightOp: 'eq',
  169. freight: '0'
  170. })
  171. draft.amount = { op: 'between', min: '20', max: '10', value: '' }
  172. assert.throws(() => m.contractFilterQuery(draft, defs), /最小值不能大于最大值/)
  173. })
  174. test('list errors remain visible, retries repeat the failed page, and identity change rejects stale results', async () => {
  175. const requests: any[] = []
  176. const h = await harness(
  177. 'MpContractList',
  178. { mode: 'manage' },
  179. {
  180. listContract: (q: any) =>
  181. new Promise((resolve, reject) => requests.push({ q, resolve, reject }))
  182. }
  183. )
  184. try {
  185. const first = h.api.fetchPage(true)
  186. requests[0].reject(new Error('离线'))
  187. await first
  188. assert.equal(h.api.errorText?.value, '离线')
  189. const retry = h.api.fetchPage(true)
  190. requests[1].resolve({ rows: [{ id: '1' }], total: 2 })
  191. await retry
  192. assert.equal(h.api.errorText.value, '')
  193. const more = h.api.fetchPage(false)
  194. requests[2].reject(new Error('下一页离线'))
  195. await more
  196. assert.equal(h.api.contracts.value.length, 1)
  197. const moreRetry = h.api.fetchPage(false)
  198. assert.equal(requests[3].q.pageNum, 2)
  199. h.user.accessToken = 'other'
  200. assert.deepEqual(h.api.contracts.value, [])
  201. requests[3].resolve({ rows: [{ id: 'stale' }], total: 2 })
  202. await moreRetry
  203. assert.deepEqual(h.api.contracts.value, [])
  204. assert.equal(h.api.loading.value, false)
  205. } finally {
  206. h.stop()
  207. }
  208. })
  209. test('list keeps default business card data and single actions with no multi-select or batch state', async () => {
  210. const h = await harness('MpContractList')
  211. try {
  212. assert.equal(h.api.selectMode, undefined)
  213. assert.equal(h.api.openBatchSubmit, undefined)
  214. h.api.contracts.value = [
  215. {
  216. id: '1',
  217. contractNo: 'C1',
  218. contractName: '合同',
  219. customerName: '客户',
  220. status: 'draft',
  221. amount: 100,
  222. freight: 10,
  223. barrelDeposit: 5,
  224. attachmentFiles: 'a,b',
  225. products: [{ productName: '产品', quantity: 2 }],
  226. ownerUserName: '负责人',
  227. createTime: '2026'
  228. }
  229. ]
  230. const fields = [...h.api.cards.value[0].previewFields, ...h.api.cards.value[0].extraFields]
  231. assert.ok(fields.find((f: any) => f.key === 'totalContractAmount')?.value.includes('115'))
  232. assert.equal(fields.find((f: any) => f.key === 'contractNo')?.value, 'C1')
  233. assert.equal(fields.find((f: any) => f.key === 'attachmentFiles')?.value, '2 个')
  234. h.api.openDetailAction('1', 'copy')
  235. assert.deepEqual(h.pushes[0], {
  236. path: '/m/crm/contract/1',
  237. query: { from: 'manage', action: 'copy' }
  238. })
  239. } finally {
  240. h.stop()
  241. }
  242. })
  243. test('approval list actions require backend approval flag and approving state', async () => {
  244. const h = await harness('MpContractList', { mode: 'approve' })
  245. try {
  246. for (const status of ['draft', 'approving', 'approved'])
  247. for (const canApprove of [false, true]) {
  248. const card = cardFrom(status, canApprove)
  249. const actions = h.api.cardActions(card).map((a: any) => a.key)
  250. assert.equal(actions.includes('approve'), status === 'approving' && canApprove)
  251. }
  252. } finally {
  253. h.stop()
  254. }
  255. function cardFrom(status: string, canApprove: boolean) {
  256. return card.buildContractCard({ id: '1', status, canApprove })
  257. }
  258. })
  259. test('three contract facets use surrounding scope, exclude themselves, and close/reopen invalidates pending loads', async () => {
  260. const m = await model()
  261. assert.deepEqual(
  262. m.contractFacetQuery(
  263. { contractNameIn: '旧', statusIn: 'draft', pageNum: 2, pageSize: 20, includeProducts: true },
  264. 'contractName'
  265. ),
  266. { statusIn: 'draft', field: 'contractName', limit: 500 }
  267. )
  268. let resolve!: (value: any) => void
  269. let count = 0
  270. const props = Vue.reactive({
  271. modelValue: true,
  272. query: { contractNameIn: '已选' },
  273. context: { statusIn: 'draft', contractNameIn: '旧', pageNum: 2 }
  274. })
  275. const h = await harness('ContractFilters', props, {
  276. fetchContractColumnFacetValues: () =>
  277. ++count === 1
  278. ? new Promise((r) => {
  279. resolve = r
  280. })
  281. : Promise.resolve({ list: [{ value: '新', label: '新候选' }] })
  282. })
  283. try {
  284. const first = h.api.loadFacet('contractName')
  285. props.modelValue = false
  286. await Vue.nextTick()
  287. assert.equal(h.api.facetLoading.contractName, false)
  288. props.modelValue = true
  289. await Vue.nextTick()
  290. await h.api.loadFacet('contractName')
  291. resolve({ list: [{ value: '旧', label: '旧候选' }] })
  292. await first
  293. h.api.searches.contractName = '新'
  294. assert.deepEqual(
  295. h.api.optionsFor('contractName').map((o: any) => o.value),
  296. ['新']
  297. )
  298. assert.deepEqual(h.api.draft.value.contractName, ['已选'])
  299. } finally {
  300. h.stop()
  301. }
  302. })
  303. test('actual list and filter templates compile and render only single-record actions', async () => {
  304. const { compileScript, compileTemplate } = await import('@vue/compiler-sfc')
  305. const { renderToString } = await import('vue/server-renderer')
  306. for (const page of ['MpContractList', 'ContractFilters']) {
  307. const props =
  308. page === 'MpContractList' ? { mode: 'manage' } : { modelValue: true, query: {}, context: {} }
  309. const h = await harness(page, props)
  310. try {
  311. const parsed = parse(h.source)
  312. assert.deepEqual(parsed.errors, [])
  313. compileScript(parsed.descriptor, { id: page })
  314. const compiled = compileTemplate({
  315. id: page,
  316. filename: page + '.vue',
  317. source: parsed.descriptor.template!.content
  318. })
  319. assert.deepEqual(compiled.errors, [])
  320. const exports: any = {}
  321. const stub = {
  322. setup:
  323. (_: any, { slots }: any) =>
  324. () =>
  325. Vue.h(
  326. 'div',
  327. Object.values(slots).flatMap((slot: any) => slot())
  328. )
  329. }
  330. new Function(
  331. 'require',
  332. 'exports',
  333. ts.transpile(compiled.code, {
  334. module: ts.ModuleKind.CommonJS,
  335. target: ts.ScriptTarget.ESNext
  336. })
  337. )(() => ({ ...Vue, resolveComponent: () => stub }), exports)
  338. if (page === 'MpContractList') {
  339. h.api.contracts.value = [
  340. {
  341. id: '1',
  342. contractNo: '唯一编号',
  343. contractName: '唯一名称',
  344. customerName: '唯一客户',
  345. status: 'draft'
  346. }
  347. ]
  348. h.api.loading.value = false
  349. const html = await renderToString(
  350. Vue.createSSRApp({ setup: () => () => exports.render(Vue.proxyRefs(h.api), []) })
  351. )
  352. for (const text of ['唯一编号', '唯一名称', '唯一客户', '复制', '提交审批'])
  353. assert.ok(html.includes(text), text)
  354. for (const text of ['多选', '已选', '批量', '字段设置', '列统计', '清空筛选', '刷新'])
  355. assert.ok(!html.includes(text), text)
  356. }
  357. } finally {
  358. h.stop()
  359. }
  360. }
  361. })
  362. test('identity reset clears a previous forbidden state so a network error remains retryable', async () => {
  363. class ForbiddenError extends Error {
  364. code = 403
  365. }
  366. let forbidden = true
  367. const h = await harness(
  368. 'MpContractList',
  369. { mode: 'manage' },
  370. {
  371. HttpError: ForbiddenError,
  372. listContract: async () => {
  373. if (forbidden) throw new ForbiddenError('无权限')
  374. throw new Error('网络失败')
  375. }
  376. }
  377. )
  378. try {
  379. await h.api.fetchPage(true)
  380. assert.equal(h.api.forbidden.value, true)
  381. h.user.accessToken = 'new'
  382. assert.equal(h.api.forbidden.value, false)
  383. forbidden = false
  384. await h.api.fetchPage(true)
  385. assert.equal(h.api.forbidden.value, false)
  386. assert.equal(h.api.errorText.value, '网络失败')
  387. } finally {
  388. h.stop()
  389. }
  390. })
  391. test('contract filter pickers preserve drafts on cancel and serialize confirmed enum, number and date values', async () => {
  392. const props = Vue.reactive({
  393. modelValue: true,
  394. query: { contractNameIn: 'A' },
  395. context: {}
  396. })
  397. const h = await harness('ContractFilters', props)
  398. try {
  399. h.api.openEnum('contractName')
  400. h.api.toggleEnum('B')
  401. h.api.enumOpen.value = false
  402. assert.deepEqual(h.api.draft.value.contractName, ['A'])
  403. h.api.openEnum('contractName')
  404. assert.deepEqual(h.api.enumDraft.value, ['A'])
  405. h.api.toggleEnum('B')
  406. h.api.confirmEnum()
  407. await Vue.nextTick()
  408. assert.equal(h.api.enumSummary('contractName'), '甲、乙')
  409. h.api.openSingle('numberOp', 'amount')
  410. h.api.confirmSingle({ selectedValues: ['gte'] })
  411. h.api.draft.value.amount.value = '100'
  412. h.api.openDate('signDate', 'from')
  413. h.api.confirmDate({ selectedValues: ['2026', '10', '24'] })
  414. h.api.apply()
  415. assert.deepEqual(h.events.find((e: any) => e[0] === 'apply')[1], {
  416. contractNameIn: 'A,B',
  417. amountOp: 'gte',
  418. amount: '100',
  419. signDateBegin: '2026-10-24'
  420. })
  421. h.api.openEnum('contractName')
  422. h.api.openSingle('sortField')
  423. h.api.openDate('signDate', 'to')
  424. props.modelValue = false
  425. await Vue.nextTick()
  426. assert.equal(h.api.enumOpen.value, false)
  427. assert.equal(h.api.singleOpen.value, false)
  428. assert.equal(h.api.dateOpen.value, false)
  429. } finally {
  430. h.stop()
  431. }
  432. })