mobile-cert-filters.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
  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 } from '../src/utils/pl-display'
  117. import { 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 merged customers, 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.stopCustomers, JSON.stringify(['装货甲', '送货乙']))
  242. assert.equal(h.requests[0].params.contractNoIn, 'C-1')
  243. assert.equal(h.requests[0].params.planDateFrom, '2026-10-08')
  244. h.api.picker.open('loadCustomer', [])
  245. assert.equal(h.requests[1].params.stopCustomers, undefined)
  246. h.api.picker.open('productContract', ['load', 'C-1'])
  247. assert.equal(h.requests[2].params.stopKindIn, undefined)
  248. assert.equal(h.requests[2].params.contractNoIn, undefined)
  249. assert.deepEqual(
  250. h.api.pickerOptions.value.map((o: any) => o.label),
  251. ['供', '需', '出库', '入库']
  252. )
  253. h.scope.stop()
  254. })
  255. test('confirmed contract summary retains side labels when another picker is active', async () => {
  256. const h = component()
  257. h.props.modelValue = true
  258. await nextTick()
  259. h.api.picker.open('productContract', ['load', 'delivery', 'C-1', '出库'])
  260. h.api.confirmPicker()
  261. h.api.picker.open('planNo', [])
  262. const labels = h.api.draft.productContract.selected.map((value: string) =>
  263. h.api.optionLabel(value, 'productContract')
  264. )
  265. assert.deepEqual(labels, ['供', '需', 'C-1', '出库'])
  266. assert.equal(h.api.optionLabel('load', 'planNo'), 'load')
  267. h.scope.stop()
  268. })
  269. import { Search as VanSearch } from 'vant'
  270. test('actual Vant Search mounts a native clear button whose activation preserves picker and selection', async () => {
  271. const h = component()
  272. h.props.modelValue = true
  273. await nextTick()
  274. h.api.picker.open('planNo', ['kept'])
  275. h.api.picker.search('PO20261021002')
  276. const source = parse(
  277. readFileSync(new URL('../src/views/m/pl/plan-cert/CertFilters.vue', import.meta.url), 'utf8')
  278. ).descriptor.template!.content
  279. const searchTemplate = source.match(/<van-search\b[\s\S]*?(?:\/>|<\/van-search>)/)![0]
  280. type Node = { type: string; props: any; children: Node[]; parent?: Node }
  281. const renderer = vue.createRenderer<Node, Node>({
  282. createElement: (type) => ({ type, props: {}, children: [] }),
  283. createText: (text) => ({ type: '#text', props: { text }, children: [] }),
  284. createComment: () => ({ type: '#comment', props: {}, children: [] }),
  285. setText: (node, text) => {
  286. node.props.text = text
  287. },
  288. setElementText: (node, text) => {
  289. node.props.text = text
  290. node.children = []
  291. },
  292. parentNode: (node) => node.parent || null,
  293. nextSibling: () => null,
  294. patchProp: (node, key, _old, value) => {
  295. node.props[key] = value
  296. },
  297. insert: (node, parent) => {
  298. node.parent = parent
  299. parent.children.push(node)
  300. },
  301. remove: (node) => {
  302. if (node.parent) node.parent.children = node.parent.children.filter((n) => n !== node)
  303. }
  304. })
  305. const root: Node = { type: 'root', props: {}, children: [] }
  306. const app = renderer.createApp({
  307. components: { VanSearch },
  308. setup: () => ({
  309. picker: h.api.picker,
  310. pickerState: h.api.picker.state,
  311. pickerTitle: '计划编号'
  312. }),
  313. render: vue.compile(searchTemplate)
  314. })
  315. app.mount(root)
  316. await nextTick()
  317. const find = (node: Node): Node | undefined =>
  318. node.type === 'button' && node.props['aria-label'] === '清空搜索'
  319. ? node
  320. : node.children.map(find).find(Boolean)
  321. const clear = find(root)
  322. assert.ok(
  323. clear,
  324. 'Search must expose a real button usable by mouse, touch-generated click and keyboard'
  325. )
  326. // Touch generates click too; both interaction paths use the same accessible native control.
  327. for (const pointerType of ['mouse', 'touch', 'keyboard']) {
  328. h.api.picker.search('PO20261021002')
  329. await nextTick()
  330. const activeClear = find(root)!
  331. assert.ok(activeClear)
  332. if (pointerType === 'mouse') {
  333. let prevented = false
  334. activeClear.props.onMousedown({
  335. preventDefault() {
  336. prevented = true
  337. }
  338. })
  339. assert.equal(prevented, true)
  340. }
  341. activeClear.props.onClick({
  342. type: 'click',
  343. pointerType,
  344. stopPropagation() {},
  345. preventDefault() {}
  346. })
  347. await nextTick()
  348. assert.equal(h.api.picker.state.keyword, '')
  349. assert.equal(find(root), undefined)
  350. assert.equal(h.api.picker.state.visible, true)
  351. assert.deepEqual(h.api.picker.state.selected, ['kept'])
  352. assert.equal(h.requests.at(-1).params.keyword, '')
  353. }
  354. app.unmount()
  355. h.scope.stop()
  356. })
  357. test('contract search filters static IO choices and has a true empty state', async () => {
  358. const h = component()
  359. h.props.modelValue = true
  360. await nextTick()
  361. h.api.picker.open('productContract', ['load'])
  362. h.api.picker.search('zz-no-contract')
  363. h.requests.at(-1).resolve({ list: [] })
  364. await nextTick()
  365. await flush()
  366. assert.deepEqual(h.api.pickerOptions.value, [])
  367. assert.deepEqual(h.api.picker.state.selected, ['load'])
  368. h.api.picker.search('入库')
  369. assert.deepEqual(
  370. h.api.pickerOptions.value.map((o: any) => o.label),
  371. ['入库']
  372. )
  373. h.scope.stop()
  374. })