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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449
  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-quote-card'
  8. import { buildQuoteColumnFilterDefs } from '../src/views/crm/quote/columnFilterDefs'
  9. async function model() {
  10. assert.ok(
  11. existsSync(new URL('../src/views/m/crm/quote/listState.ts', import.meta.url)),
  12. 'mobile quote query model must exist'
  13. )
  14. return import('../src/views/m/crm/quote/listState')
  15. }
  16. test('all/submitted/accepted use the Web implicit scopes and explicit status overrides in both modes', async () => {
  17. const m = await model()
  18. for (const mode of ['manage', 'approve'] as const)
  19. for (const view of ['all', 'submitted', 'accepted'] as const) {
  20. const base = {
  21. mode,
  22. view,
  23. keyword: ' 搜索 ',
  24. currentUserId: 'u1',
  25. pageNum: 2,
  26. pageSize: 20,
  27. visible: m.quoteColumnDefs.map((d) => d.prop)
  28. }
  29. const q = m.buildMobileQuoteQuery({
  30. ...base,
  31. query: { quoteNo: '旧', quoteTitle: '旧', customerName: '旧', quoteNoIn: 'Q1' }
  32. })
  33. assert.equal(q.excludeStatus, 'invalid')
  34. assert.equal(q.keyword, '搜索')
  35. assert.equal(q.quoteTitle, undefined)
  36. assert.equal(q.quoteNoIn, 'Q1')
  37. assert.equal(q.status, view === 'submitted' ? 'submitted' : undefined)
  38. assert.equal(q.pendingApprovalUserId, view === 'submitted' ? 'u1' : undefined)
  39. assert.equal(q.approvedByUserId, view === 'accepted' ? 'u1' : undefined)
  40. const explicit = m.buildMobileQuoteQuery({ ...base, query: { statusIn: 'invalid,approved' } })
  41. assert.equal(explicit.excludeStatus, undefined)
  42. assert.equal(explicit.status, undefined)
  43. assert.equal(explicit.statusIn, 'invalid,approved')
  44. }
  45. })
  46. test('filter serialization maps dates, enum and numbers and rejects reversed ranges; hidden filters and sorts are removed', async () => {
  47. const m = await model()
  48. const defs = buildQuoteColumnFilterDefs(() => [])
  49. const draft = m.createQuoteFilterDraft(
  50. {
  51. quoteNoIn: 'Q1,Q2',
  52. quoteDateBegin: '2026-01-01',
  53. beginTime: '2026-02-01',
  54. finalAmountOp: 'gte',
  55. finalAmount: '0'
  56. },
  57. defs
  58. )
  59. assert.deepEqual(m.quoteFilterQuery(draft, defs), {
  60. quoteNoIn: 'Q1,Q2',
  61. quoteDateBegin: '2026-01-01',
  62. beginTime: '2026-02-01',
  63. finalAmountOp: 'gte',
  64. finalAmount: '0'
  65. })
  66. draft.finalAmount = { op: 'between', min: '5', max: '2', value: '' }
  67. assert.throws(() => m.quoteFilterQuery(draft, defs), /最小值不能大于最大值/)
  68. draft.finalAmount = { op: 'between', min: '', max: '', value: '' }
  69. draft.quoteDate = { from: '2026-03-01', to: '2026-02-01' }
  70. assert.throws(() => m.quoteFilterQuery(draft, defs), /开始日期不能晚于结束日期/)
  71. assert.deepEqual(
  72. m.cleanQuoteQuery(
  73. {
  74. quoteNoIn: 'Q',
  75. quoteTitle: 'T',
  76. statusIn: 'invalid',
  77. beginTime: '2026',
  78. sortField: 'createTime',
  79. sortOrder: 'desc'
  80. },
  81. []
  82. ),
  83. { quoteNoIn: 'Q', quoteTitle: 'T' }
  84. )
  85. })
  86. async function harness(
  87. page: string,
  88. props: any = { mode: 'manage' },
  89. overrides: Record<string, any> = {}
  90. ) {
  91. const file = new URL(`../src/views/m/crm/quote/${page}.vue`, import.meta.url)
  92. assert.ok(existsSync(file), `${page} must exist`)
  93. const source = readFileSync(file, 'utf8')
  94. const ast = ts.createSourceFile(
  95. 'page.ts',
  96. parse(source).descriptor.scriptSetup!.content,
  97. ts.ScriptTarget.Latest,
  98. true
  99. )
  100. const statements = ast.statements.filter((s) => !ts.isImportDeclaration(s))
  101. const names = statements.flatMap((s) =>
  102. ts.isFunctionDeclaration(s) && s.name
  103. ? [s.name.text]
  104. : ts.isVariableStatement(s)
  105. ? s.declarationList.declarations.flatMap((d) =>
  106. ts.isIdentifier(d.name)
  107. ? [d.name.text]
  108. : ts.isObjectBindingPattern(d.name)
  109. ? d.name.elements.map((e) => e.name.getText(ast))
  110. : []
  111. )
  112. : []
  113. )
  114. const bindings: Record<string, any> = { ...Vue }
  115. for (const s of ast.statements)
  116. if (ts.isImportDeclaration(s)) {
  117. if (s.importClause?.name) bindings[s.importClause.name.text] = () => {}
  118. const n = s.importClause?.namedBindings
  119. if (n && ts.isNamedImports(n)) for (const e of n.elements) bindings[e.name.text] = () => {}
  120. }
  121. const requests: any[] = [],
  122. events: any[] = [],
  123. pushes: any[] = [],
  124. facets: any[] = []
  125. const user = Vue.reactive({ accessToken: 'token', info: { id: 'u1' }, getUserInfo: { id: 'u1' } })
  126. Object.assign(
  127. bindings,
  128. card,
  129. await model(),
  130. {
  131. buildQuoteColumnFilterDefs,
  132. defineProps: () => props,
  133. defineEmits:
  134. () =>
  135. (...args: any[]) =>
  136. events.push(args),
  137. useRouter: () => ({ push: (v: any) => pushes.push(v) }),
  138. useUserStore: () => user,
  139. useDictStore: () => ({
  140. getOptions: () => [{ value: 'invalid', label: '作废' }],
  141. getLabel: (_: any, value: any) => value,
  142. getTagType: () => 'info'
  143. }),
  144. useListReturnState: () => ({ restoring: Vue.ref(false), restore: async () => {} }),
  145. useIntersectionObserver: () => {},
  146. useDebounceFn: (fn: any) => fn,
  147. listQuote: async (q: any) => {
  148. requests.push(q)
  149. return { rows: [], total: 0 }
  150. },
  151. fetchQuoteColumnFacetValues: async (q: any) => {
  152. facets.push(q)
  153. return {
  154. list: [
  155. { value: 'A', label: '甲' },
  156. { value: 'B', label: '乙' }
  157. ]
  158. }
  159. },
  160. HttpError: class extends Error {},
  161. ApiStatus: { forbidden: 403 },
  162. onMounted: () => {},
  163. onBeforeUnmount: () => {}
  164. },
  165. overrides
  166. )
  167. for (const key of Object.keys(bindings))
  168. if (!/^[a-zA-Z_$][\w$]*$/.test(key) || key === 'default') delete bindings[key]
  169. const code = ts.transpile(statements.map((s) => s.getText(ast)).join('\n'), {
  170. target: ts.ScriptTarget.ESNext
  171. })
  172. const scope = Vue.effectScope()
  173. const api = scope.run(() =>
  174. new Function(...Object.keys(bindings), `${code}; return {${names.join(',')}}`)(
  175. ...Object.values(bindings)
  176. )
  177. )
  178. return { api, requests, events, pushes, facets, user, source, stop: () => scope.stop() }
  179. }
  180. test('both list modes use fixed Web defaults regardless of old mobile preferences', async () => {
  181. const m = await model()
  182. Object.assign(globalThis, {
  183. localStorage: {
  184. getItem: () => '[]',
  185. setItem: () => {
  186. throw new Error('must not persist columns')
  187. }
  188. }
  189. })
  190. try {
  191. for (const mode of ['manage', 'approve']) {
  192. const h = await harness('MpQuoteList', { mode })
  193. try {
  194. assert.deepEqual([...h.api.visibleColumns.value], m.defaultQuoteColumns)
  195. assert.equal(h.api.visibleColumns.value.length, 11)
  196. assert.equal(h.api.selecting, undefined)
  197. assert.equal(h.api.columnsOpen, undefined)
  198. h.api.applyFilters({ statusIn: 'invalid', contactEmail: 'hidden', quoteTitle: '主题' })
  199. assert.deepEqual(h.api.query.value, { statusIn: 'invalid', quoteTitle: '主题' })
  200. h.api.quotes.value = [{ id: '1' }]
  201. h.user.accessToken = 'other'
  202. assert.deepEqual(h.api.quotes.value, [])
  203. } finally {
  204. h.stop()
  205. }
  206. }
  207. } finally {
  208. delete (globalThis as any).localStorage
  209. }
  210. })
  211. test('failed requests provide a retryable error and a successful retry clears it without skipping page', async () => {
  212. let failing = true
  213. const requested: any[] = []
  214. const h = await harness(
  215. 'MpQuoteList',
  216. { mode: 'manage' },
  217. {
  218. listQuote: async (q: any) => {
  219. requested.push(q)
  220. if (failing) throw new Error('离线')
  221. return { rows: [{ id: '1' }], total: 2 }
  222. }
  223. }
  224. )
  225. try {
  226. await h.api.fetchPage(true)
  227. assert.equal(h.api.errorText.value, '离线')
  228. failing = false
  229. await h.api.fetchPage(true)
  230. assert.equal(h.api.errorText.value, '')
  231. assert.equal(h.api.quotes.value.length, 1)
  232. failing = true
  233. await h.api.fetchPage(false)
  234. failing = false
  235. await h.api.fetchPage(false)
  236. assert.deepEqual(
  237. requested.map((q) => q.pageNum),
  238. [1, 1, 2, 2]
  239. )
  240. } finally {
  241. h.stop()
  242. }
  243. })
  244. test('quote facet excludes itself and page context, searches locally, and closing pending load permits retry', async () => {
  245. const props = Vue.reactive({
  246. modelValue: true,
  247. query: { customerNameIn: 'A' },
  248. context: { keyword: '酸', customerNameIn: 'A', pendingApprovalUserId: 'u1', pageNum: 2 },
  249. visibleColumns: ['customerName']
  250. })
  251. let resolve!: (value: any) => void
  252. let calls = 0
  253. const h = await harness('QuoteFilters', props, {
  254. fetchQuoteColumnFacetValues: (q: any) => {
  255. calls++
  256. h.facets.push(q)
  257. return calls === 1
  258. ? new Promise((r) => {
  259. resolve = r
  260. })
  261. : Promise.resolve({ list: [{ value: 'B', label: '乙' }] })
  262. }
  263. })
  264. try {
  265. const first = h.api.loadFacet('customerName')
  266. props.modelValue = false
  267. await Vue.nextTick()
  268. assert.equal(h.api.facetLoading.customerName, false)
  269. props.modelValue = true
  270. await Vue.nextTick()
  271. await h.api.loadFacet('customerName')
  272. resolve({ list: [{ value: 'old', label: '旧' }] })
  273. await first
  274. assert.deepEqual(h.facets.at(-1), {
  275. keyword: '酸',
  276. pendingApprovalUserId: 'u1',
  277. field: 'customerName',
  278. limit: 500
  279. })
  280. h.api.searches.customerName = '乙'
  281. assert.deepEqual(
  282. h.api.optionsFor('customerName').map((o: any) => o.value),
  283. ['B']
  284. )
  285. assert.deepEqual(h.api.draft.value.customerName, ['A'])
  286. } finally {
  287. h.stop()
  288. }
  289. })
  290. test('tab filters are independent and single-record copy retains its route', async () => {
  291. const h = await harness('MpQuoteList')
  292. try {
  293. h.api.keyword.value = '全部关键词'
  294. h.api.query.value = { quoteNoIn: 'Q' }
  295. h.api.view.value = 'submitted'
  296. h.api.onViewChange('submitted')
  297. assert.equal(h.api.keyword.value, '')
  298. h.api.query.value = { customerNameIn: '客户' }
  299. h.api.view.value = 'all'
  300. h.api.onViewChange('all')
  301. assert.equal(h.api.keyword.value, '全部关键词')
  302. assert.deepEqual(h.api.query.value, { quoteNoIn: 'Q' })
  303. h.api.openDetailAction('1', 'copy')
  304. assert.deepEqual(h.pushes[0], {
  305. path: '/m/crm/quote/1',
  306. query: { from: 'manage', action: 'copy' }
  307. })
  308. } finally {
  309. h.stop()
  310. }
  311. })
  312. test('actual list and filter SFC retain default fields without removed list tools', async () => {
  313. const { compileScript, compileTemplate } = await import('@vue/compiler-sfc')
  314. const { renderToString } = await import('vue/server-renderer')
  315. const h = await harness('MpQuoteList')
  316. try {
  317. h.api.quotes.value = [
  318. {
  319. id: '1',
  320. quoteTitle: '固定主题(固定单号)',
  321. quoteNo: '固定单号',
  322. status: '唯一状态',
  323. createDeptName: '唯一部门',
  324. updateByName: '唯一更新人'
  325. }
  326. ]
  327. h.api.loading.value = false
  328. const descriptor = parse(h.source).descriptor
  329. compileScript(descriptor, { id: 'quote-list' })
  330. const template = compileTemplate({
  331. id: 'quote-list',
  332. filename: 'MpQuoteList.vue',
  333. source: descriptor.template!.content
  334. })
  335. assert.deepEqual(template.errors, [])
  336. const exports: any = {}
  337. const stub = {
  338. setup:
  339. (_: any, { slots }: any) =>
  340. () =>
  341. Vue.h(
  342. 'div',
  343. Object.values(slots).flatMap((slot: any) => slot())
  344. )
  345. }
  346. new Function(
  347. 'require',
  348. 'exports',
  349. ts.transpile(template.code, {
  350. module: ts.ModuleKind.CommonJS,
  351. target: ts.ScriptTarget.ESNext
  352. })
  353. )(() => ({ ...Vue, resolveComponent: () => stub }), exports)
  354. const render = () =>
  355. renderToString(
  356. Vue.createSSRApp({ setup: () => () => exports.render(Vue.proxyRefs(h.api), []) })
  357. )
  358. const shown = await render()
  359. assert.match(shown, /class="mp-wv-card__no">固定主题<\/div>/)
  360. assert.match(shown, /class="mp-quote-list-card__number">固定单号<\/span>/)
  361. assert.equal(h.api.quotes.value[0].quoteTitle, '固定主题(固定单号)')
  362. for (const value of ['固定主题', '固定单号', '唯一状态']) assert.ok(shown.includes(value))
  363. for (const value of [
  364. '唯一部门',
  365. '唯一更新人',
  366. '刷新',
  367. '清空筛选',
  368. '字段设置',
  369. '列统计',
  370. '多选',
  371. '选择已加载'
  372. ])
  373. assert.ok(!shown.includes(value), value)
  374. const filters = parse(
  375. readFileSync(new URL('../src/views/m/crm/quote/QuoteFilters.vue', import.meta.url), 'utf8')
  376. ).descriptor
  377. const script = compileScript(filters, { id: 'quote-filters' })
  378. assert.deepEqual(
  379. compileTemplate({
  380. id: 'quote-filters',
  381. filename: 'QuoteFilters.vue',
  382. source: filters.template!.content,
  383. compilerOptions: { bindingMetadata: script.bindings }
  384. }).errors,
  385. []
  386. )
  387. } finally {
  388. h.stop()
  389. }
  390. })
  391. test('approval list requires explicit backend permission before displaying approve and reject actions', async () => {
  392. const h = await harness('MpQuoteList', { mode: 'approve' })
  393. try {
  394. for (const flag of [false, undefined, true]) {
  395. h.api.quotes.value = [{ id: '1', status: 'submitted', canApprove: flag }]
  396. const actions = h.api.cardActions(h.api.cards.value[0]).map((a: any) => a.key)
  397. assert.equal(actions.includes('approve'), flag === true)
  398. assert.equal(actions.includes('reject'), flag === true)
  399. assert.ok(actions.includes('view'))
  400. }
  401. } finally {
  402. h.stop()
  403. }
  404. })
  405. test('quote filter pickers preserve drafts on cancel and serialize confirmed enum, number and date values', async () => {
  406. const props = Vue.reactive({
  407. modelValue: true,
  408. query: { quoteTypeIn: 'sales' },
  409. context: {},
  410. visibleColumns: ['quoteType', 'finalAmount', 'quoteDate']
  411. })
  412. const h = await harness('QuoteFilters', props)
  413. try {
  414. h.api.openEnum('quoteType')
  415. h.api.toggleEnum('purchase')
  416. h.api.enumOpen.value = false
  417. assert.deepEqual(h.api.draft.value.quoteType, ['sales'])
  418. h.api.openEnum('quoteType')
  419. assert.deepEqual(h.api.enumDraft.value, ['sales'])
  420. h.api.toggleEnum('purchase')
  421. h.api.confirmEnum()
  422. assert.equal(h.api.enumSummary('quoteType'), '销售、采购')
  423. h.api.openSingle('numberOp', 'finalAmount')
  424. h.api.confirmSingle({ selectedValues: ['gte'] })
  425. h.api.draft.value.finalAmount.value = '100'
  426. h.api.openDate('quoteDate', 'from')
  427. h.api.confirmDate({ selectedValues: ['2026', '10', '24'] })
  428. h.api.apply()
  429. assert.deepEqual(h.events.find((e: any) => e[0] === 'apply')[1], {
  430. quoteTypeIn: 'sales,purchase',
  431. finalAmountOp: 'gte',
  432. finalAmount: '100',
  433. quoteDateBegin: '2026-10-24'
  434. })
  435. h.api.openEnum('quoteType')
  436. h.api.openSingle('sortField')
  437. h.api.openDate('quoteDate', 'to')
  438. props.modelValue = false
  439. await Vue.nextTick()
  440. assert.equal(h.api.enumOpen.value, false)
  441. assert.equal(h.api.singleOpen.value, false)
  442. assert.equal(h.api.dateOpen.value, false)
  443. } finally {
  444. h.stop()
  445. }
  446. })