mobile-cert-filters.test.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432
  1. import assert from 'node:assert/strict'
  2. import test from 'node:test'
  3. import { effectScope, reactive, nextTick } from 'vue'
  4. import { useMobileCertFacet } from '../src/views/m/pl/plan-cert/useMobileCertFacet'
  5. function harness() {
  6. const requests: any[] = []
  7. const context = reactive({ planNoIn: 'chosen', planType: 'outbound', page: 2, sortField: 'date' })
  8. const account = reactive({ token: 'a' })
  9. const scope = effectScope()
  10. const api = scope.run(() =>
  11. useMobileCertFacet({
  12. defs: [{ prop: 'planNo', type: 'enum', label: '计划编号' }],
  13. context: () => context,
  14. account: () => account.token,
  15. fetch: (params: any) =>
  16. new Promise((resolve, reject) => requests.push({ params, resolve, reject }))
  17. })
  18. )!
  19. return { api, requests, context, account, scope }
  20. }
  21. const flush = async () => {
  22. await Promise.resolve()
  23. await nextTick()
  24. }
  25. test('remote keyword finds values outside first 500; strips self and paging; preserves selection and cancel', async () => {
  26. const h = harness()
  27. h.api.open('planNo', ['chosen'])
  28. assert.equal(h.requests[0].params.planNoIn, undefined)
  29. assert.equal(h.requests[0].params.page, undefined)
  30. assert.equal(h.requests[0].params.sortField, undefined)
  31. h.requests[0].resolve({ list: Array.from({ length: 500 }, (_, i) => ({ value: `${i}` })) })
  32. await flush()
  33. h.api.search('PO20261009022')
  34. assert.equal(h.requests[1].params.keyword, 'PO20261009022')
  35. h.requests[1].resolve({ data: { list: [{ value: 'PO20261009022' }] } })
  36. await flush()
  37. assert.equal(h.api.state.options[0].value, 'PO20261009022')
  38. h.api.toggle('PO20261009022')
  39. h.api.search('no result')
  40. h.requests[2].resolve({ list: [] })
  41. await flush()
  42. assert.deepEqual(h.api.confirm(), { field: 'planNo', selected: ['chosen', 'PO20261009022'] })
  43. h.api.open('planNo', ['chosen'])
  44. h.api.toggle('changed')
  45. h.api.close()
  46. assert.equal(h.api.confirm(), null)
  47. h.api.open('planNo', ['chosen'])
  48. assert.deepEqual(h.api.state.selected, ['chosen'])
  49. h.scope.stop()
  50. })
  51. test('reverse query success/error/finally cannot replace active request; retry is distinct from empty', async () => {
  52. const h = harness()
  53. h.api.open('planNo', [])
  54. h.api.search('new')
  55. h.requests[0].reject(new Error('stale'))
  56. await flush()
  57. assert.equal(h.api.state.error, '')
  58. assert.equal(h.api.state.loading, true)
  59. h.requests[1].reject(new Error('network'))
  60. await flush()
  61. assert.equal(h.api.state.error, 'network')
  62. assert.equal(h.api.state.loading, false)
  63. h.api.retry()
  64. h.requests[2].resolve({ list: [] })
  65. await flush()
  66. assert.equal(h.api.state.error, '')
  67. assert.deepEqual(h.api.state.options, [])
  68. h.api.search('older')
  69. h.api.search('latest')
  70. h.requests[4].resolve({ list: [{ value: 'latest' }] })
  71. await flush()
  72. h.requests[3].resolve({ list: [{ value: 'older' }] })
  73. await flush()
  74. assert.equal(h.api.state.options[0].value, 'latest')
  75. h.scope.stop()
  76. })
  77. test('close/reopen, context, account and disposal invalidate requests and confirmation', async () => {
  78. const h = harness()
  79. h.api.open('planNo', ['a'])
  80. h.api.close()
  81. h.api.open('planNo', ['b'])
  82. h.requests[0].resolve({ list: [{ value: 'old' }] })
  83. await flush()
  84. assert.deepEqual(h.api.state.options, [])
  85. assert.equal(h.api.state.loading, true)
  86. h.context.planType = 'inbound'
  87. assert.equal(h.api.confirm(), null)
  88. h.requests[1].reject(new Error('stale'))
  89. await flush()
  90. assert.equal(h.api.state.error, '')
  91. h.api.open('planNo', ['b'])
  92. h.account.token = 'b'
  93. assert.equal(h.api.confirm(), null)
  94. h.api.open('planNo', ['c'])
  95. h.scope.stop()
  96. h.requests[3].resolve({ list: [{ value: 'after disposal' }] })
  97. await flush()
  98. assert.equal(h.api.confirm(), null)
  99. assert.deepEqual(h.api.state.options, [])
  100. })
  101. // Execute the actual SFC setup with Vue reactivity; only transport/store/UI effects are substituted.
  102. import { readFileSync } from 'node:fs'
  103. import { parse } from '@vue/compiler-sfc'
  104. import ts from 'typescript'
  105. import * as vue from 'vue'
  106. import { useServerColumnFilters } from '../src/composables/useServerColumnFilters'
  107. import { plPlanCertColumnFilterDefs as defs } from '../src/views/pl/plan-cert/columnFilterDefs'
  108. import {
  109. withPlanContractIoFacetOptions,
  110. PL_PLAN_CONTRACT_SIDE_FILTER_OPTIONS
  111. } from '../src/utils/pl-plan-contract-display'
  112. import {
  113. applyMergedCustomerQueryParams,
  114. applyContractSideQueryParams
  115. } from '../src/utils/pl-plan-pair-list-query'
  116. import { PL_PLAN_DEFAULT_FOLLOW_STATUSES, plPlanCertTabToFilters } from '../src/utils/pl-display'
  117. import { buildQuery, facetPlanDateParams } from '../src/views/m/pl/plan-cert/model'
  118. function component() {
  119. const scope = effectScope()
  120. const requests: any[] = [],
  121. events: any[] = [],
  122. warnings: string[] = []
  123. const props = reactive({
  124. modelValue: false,
  125. filters: {} as any,
  126. sort: { sortField: '', sortOrder: 'desc' },
  127. context: {
  128. planType: 'outbound',
  129. certUploadStatus: 'incomplete',
  130. planDateFrom: '2026-10-01',
  131. followStatus: 'accepted'
  132. } as any
  133. })
  134. const source = parse(
  135. readFileSync(new URL('../src/views/m/pl/plan-cert/CertFilters.vue', import.meta.url), 'utf8')
  136. ).descriptor.scriptSetup!.content
  137. const ast = ts.createSourceFile('setup.ts', source, ts.ScriptTarget.Latest, true)
  138. const code = ts.transpile(
  139. ast.statements
  140. .filter((n) => !ts.isImportDeclaration(n))
  141. .map((n) => n.getText(ast))
  142. .join('\n'),
  143. { target: ts.ScriptTarget.ESNext }
  144. )
  145. const deps = {
  146. ref: vue.ref,
  147. computed: vue.computed,
  148. watch: vue.watch,
  149. defs,
  150. useServerColumnFilters,
  151. useMobileCertFacet,
  152. withPlanContractIoFacetOptions,
  153. PL_PLAN_CONTRACT_SIDE_FILTER_OPTIONS,
  154. applyMergedCustomerQueryParams,
  155. applyContractSideQueryParams,
  156. PL_PLAN_DEFAULT_FOLLOW_STATUSES,
  157. facetPlanDateParams,
  158. useUserStore: () => reactive({ accessToken: 'a', getUserInfo: { tenantNo: 'a' } }),
  159. fetchPlanColumnFacetValues: (params: any) =>
  160. new Promise((resolve, reject) => requests.push({ params, resolve, reject })),
  161. defineProps: () => props,
  162. defineEmits:
  163. () =>
  164. (...args: any[]) =>
  165. events.push(args),
  166. ElMessage: { warning: (v: string) => warnings.push(v) }
  167. }
  168. const api = scope.run(() =>
  169. new Function(
  170. ...Object.keys(deps),
  171. `${code}; return {picker,draft,apply,resetAndApply,confirmPicker,optionLabel,pickerOptions}`
  172. )(...Object.values(deps))
  173. )
  174. return { api, props, requests, events, warnings, scope }
  175. }
  176. test('component preserves query semantics and confirmation is isolated until accepted', async () => {
  177. const h = component()
  178. h.props.modelValue = true
  179. await nextTick()
  180. Object.assign(h.api.draft, {
  181. followStatus: { selected: ['accepted'], text: '' },
  182. planType: { selected: ['inbound'], text: '' },
  183. hazardCertReady: { selected: ['1'], text: '' },
  184. productContract: { selected: ['load', 'C-1'], text: '' }
  185. })
  186. h.api.picker.open('planNo', [])
  187. const q = h.requests[0].params
  188. assert.equal(q.planType, 'outbound')
  189. assert.equal(q.hazardCertReady, 0)
  190. assert.equal(q.hazardCertReadyIn, undefined)
  191. assert.equal(q.followStatus, 'accepted')
  192. assert.equal(q.planDateFrom, '2026-10-01')
  193. assert.equal(q.stopKindIn, 'load')
  194. h.api.picker.toggle('P-1')
  195. assert.equal(h.api.draft.planNo, undefined)
  196. h.api.confirmPicker()
  197. assert.deepEqual(h.api.draft.planNo.selected, ['P-1'])
  198. h.api.picker.open('productContract', ['load', 'C-1'])
  199. h.api.picker.search('no match')
  200. assert.equal(h.api.optionLabel('load'), '供')
  201. assert.equal(h.api.optionLabel('C-1'), 'C-1')
  202. h.scope.stop()
  203. })
  204. test('component date/number validation, normal apply, reset and closing discard picker', async () => {
  205. const h = component()
  206. h.props.modelValue = true
  207. await nextTick()
  208. h.api.draft.planDate = { from: '2026-10-02', to: '2026-10-01' }
  209. h.api.apply()
  210. assert.equal(h.events.length, 0)
  211. assert.match(h.warnings[0], /开始不能晚于结束/)
  212. delete h.api.draft.planDate
  213. const number = defs.find((d) => d.type === 'number')!
  214. h.api.draft[number.prop] = { op: 'between', min: '9', max: '2' }
  215. h.api.apply()
  216. assert.equal(h.events.length, 0)
  217. assert.match(h.warnings[1], /最小值不能大于最大值/)
  218. h.api.draft[number.prop] = { op: 'between', min: '2', max: '9' }
  219. h.api.apply()
  220. assert.equal(h.events[0][0], 'apply')
  221. h.api.resetAndApply()
  222. assert.deepEqual(h.events[2][1].followStatus.selected, PL_PLAN_DEFAULT_FOLLOW_STATUSES)
  223. assert.equal(h.events[2][3], true)
  224. h.api.picker.open('planNo', [])
  225. h.props.modelValue = false
  226. await nextTick()
  227. assert.equal(h.api.picker.confirm(), null)
  228. h.scope.stop()
  229. })
  230. test('component independent customer sides, contract-side stripping and draft date replacement use real query helpers', async () => {
  231. const h = component()
  232. h.props.modelValue = true
  233. await nextTick()
  234. Object.assign(h.api.draft, {
  235. loadCustomer: { selected: ['装货甲'], text: '' },
  236. deliveryCustomer: { selected: ['送货乙'], text: '' },
  237. productContract: { selected: ['load', 'C-1'], text: '' },
  238. planDate: { from: '2026-10-08', to: '2026-10-09' }
  239. })
  240. h.api.picker.open('planNo', [])
  241. assert.equal(h.requests[0].params.loadCustomersIn, '装货甲')
  242. assert.equal(h.requests[0].params.deliveryCustomersIn, '送货乙')
  243. assert.equal(h.requests[0].params.stopCustomers, undefined)
  244. assert.equal(h.requests[0].params.contractNoIn, 'C-1')
  245. assert.equal(h.requests[0].params.planDateFrom, '2026-10-08')
  246. h.api.picker.open('loadCustomer', [])
  247. assert.equal(h.requests[1].params.stopCustomers, undefined)
  248. h.api.picker.open('productContract', ['load', 'C-1'])
  249. assert.equal(h.requests[2].params.stopKindIn, undefined)
  250. assert.equal(h.requests[2].params.contractNoIn, undefined)
  251. assert.deepEqual(
  252. h.api.pickerOptions.value.map((o: any) => o.label),
  253. ['供', '需', '出库', '入库']
  254. )
  255. h.scope.stop()
  256. })
  257. test('confirmed contract summary retains side labels when another picker is active', async () => {
  258. const h = component()
  259. h.props.modelValue = true
  260. await nextTick()
  261. h.api.picker.open('productContract', ['load', 'delivery', 'C-1', '出库'])
  262. h.api.confirmPicker()
  263. h.api.picker.open('planNo', [])
  264. const labels = h.api.draft.productContract.selected.map((value: string) =>
  265. h.api.optionLabel(value, 'productContract')
  266. )
  267. assert.deepEqual(labels, ['供', '需', 'C-1', '出库'])
  268. assert.equal(h.api.optionLabel('load', 'planNo'), 'load')
  269. h.scope.stop()
  270. })
  271. import { Search as VanSearch } from 'vant'
  272. test('actual Vant Search mounts a native clear button whose activation preserves picker and selection', async () => {
  273. const h = component()
  274. h.props.modelValue = true
  275. await nextTick()
  276. h.api.picker.open('planNo', ['kept'])
  277. h.api.picker.search('PO20261021002')
  278. const source = parse(
  279. readFileSync(new URL('../src/views/m/pl/plan-cert/CertFilters.vue', import.meta.url), 'utf8')
  280. ).descriptor.template!.content
  281. const searchTemplate = source.match(/<van-search\b[\s\S]*?(?:\/>|<\/van-search>)/)![0]
  282. type Node = { type: string; props: any; children: Node[]; parent?: Node }
  283. const renderer = vue.createRenderer<Node, Node>({
  284. createElement: (type) => ({ type, props: {}, children: [] }),
  285. createText: (text) => ({ type: '#text', props: { text }, children: [] }),
  286. createComment: () => ({ type: '#comment', props: {}, children: [] }),
  287. setText: (node, text) => {
  288. node.props.text = text
  289. },
  290. setElementText: (node, text) => {
  291. node.props.text = text
  292. node.children = []
  293. },
  294. parentNode: (node) => node.parent || null,
  295. nextSibling: () => null,
  296. patchProp: (node, key, _old, value) => {
  297. node.props[key] = value
  298. },
  299. insert: (node, parent) => {
  300. node.parent = parent
  301. parent.children.push(node)
  302. },
  303. remove: (node) => {
  304. if (node.parent) node.parent.children = node.parent.children.filter((n) => n !== node)
  305. }
  306. })
  307. const root: Node = { type: 'root', props: {}, children: [] }
  308. const app = renderer.createApp({
  309. components: { VanSearch },
  310. setup: () => ({
  311. picker: h.api.picker,
  312. pickerState: h.api.picker.state,
  313. pickerTitle: '计划编号'
  314. }),
  315. render: vue.compile(searchTemplate)
  316. })
  317. app.mount(root)
  318. await nextTick()
  319. const find = (node: Node): Node | undefined =>
  320. node.type === 'button' && node.props['aria-label'] === '清空搜索'
  321. ? node
  322. : node.children.map(find).find(Boolean)
  323. const clear = find(root)
  324. assert.ok(
  325. clear,
  326. 'Search must expose a real button usable by mouse, touch-generated click and keyboard'
  327. )
  328. // Touch generates click too; both interaction paths use the same accessible native control.
  329. for (const pointerType of ['mouse', 'touch', 'keyboard']) {
  330. h.api.picker.search('PO20261021002')
  331. await nextTick()
  332. const activeClear = find(root)!
  333. assert.ok(activeClear)
  334. if (pointerType === 'mouse') {
  335. let prevented = false
  336. activeClear.props.onMousedown({
  337. preventDefault() {
  338. prevented = true
  339. }
  340. })
  341. assert.equal(prevented, true)
  342. }
  343. activeClear.props.onClick({
  344. type: 'click',
  345. pointerType,
  346. stopPropagation() {},
  347. preventDefault() {}
  348. })
  349. await nextTick()
  350. assert.equal(h.api.picker.state.keyword, '')
  351. assert.equal(find(root), undefined)
  352. assert.equal(h.api.picker.state.visible, true)
  353. assert.deepEqual(h.api.picker.state.selected, ['kept'])
  354. assert.equal(h.requests.at(-1).params.keyword, '')
  355. }
  356. app.unmount()
  357. h.scope.stop()
  358. })
  359. test('contract search filters static IO choices and has a true empty state', async () => {
  360. const h = component()
  361. h.props.modelValue = true
  362. await nextTick()
  363. h.api.picker.open('productContract', ['load'])
  364. h.api.picker.search('zz-no-contract')
  365. h.requests.at(-1).resolve({ list: [] })
  366. await nextTick()
  367. await flush()
  368. assert.deepEqual(h.api.pickerOptions.value, [])
  369. assert.deepEqual(h.api.picker.state.selected, ['load'])
  370. h.api.picker.search('入库')
  371. assert.deepEqual(
  372. h.api.pickerOptions.value.map((o: any) => o.label),
  373. ['入库']
  374. )
  375. h.scope.stop()
  376. })
  377. for (const tab of ['all', 'cert_incomplete', 'inbound', 'outbound']) {
  378. test(`certificate ${tab} list query matches Web for independent customers and compound filters`, () => {
  379. const { filters, toQueryParams } = useServerColumnFilters(defs)
  380. Object.assign(filters, {
  381. followStatus: { text: '', selected: ['accepted'] },
  382. loadCustomer: { text: '', selected: ['装货甲'] },
  383. deliveryCustomer: { text: '', selected: ['送货乙'] },
  384. productContract: { text: '', selected: ['load', 'C-1'] },
  385. hazardCertReady: { text: '', selected: ['1'] }
  386. })
  387. const sort = { sortField: 'planDate', sortOrder: 'desc' }
  388. const env = {
  389. filters,
  390. toQueryParams,
  391. sort,
  392. buildQuery,
  393. plPlanCertTabToFilters,
  394. applyMergedCustomerQueryParams,
  395. applyContractSideQueryParams,
  396. tab: vue.ref(tab),
  397. activePlanTypeTab: vue.ref(tab),
  398. appliedKeyword: vue.ref(' 运输 '),
  399. quickSearch: vue.ref(' 运输 '),
  400. dates: vue.ref(['2026-10-01', '2026-10-24']),
  401. dateRange: vue.ref(['2026-10-01', '2026-10-24']),
  402. toSortQueryParams: () => sort
  403. }
  404. const evaluate = (path: string, name: string) => {
  405. const ast = ts.createSourceFile(
  406. 'query.ts',
  407. parse(readFileSync(new URL(path, import.meta.url), 'utf8')).descriptor.scriptSetup!.content,
  408. ts.ScriptTarget.Latest,
  409. true
  410. )
  411. const fn = ast.statements.find((n) => ts.isFunctionDeclaration(n) && n.name?.text === name)!
  412. return new Function(
  413. ...Object.keys(env),
  414. `${ts.transpile(fn.getText(ast), { target: ts.ScriptTarget.ESNext })};return ${name}()`
  415. )(...Object.values(env))
  416. }
  417. const mobile = evaluate('../src/views/m/pl/plan-cert/index.vue', 'query')
  418. assert.deepEqual(
  419. mobile,
  420. evaluate('../src/views/pl/plan-cert/index.vue', 'buildListQueryParams')
  421. )
  422. assert.equal(mobile.loadCustomersIn, '装货甲')
  423. assert.equal(mobile.deliveryCustomersIn, '送货乙')
  424. })
  425. }