Files
shanxia2.0/web-admin/scripts/mobile-cert-filters.test.ts

433 lines
16 KiB
TypeScript

import assert from 'node:assert/strict'
import test from 'node:test'
import { effectScope, reactive, nextTick } from 'vue'
import { useMobileCertFacet } from '../src/views/m/pl/plan-cert/useMobileCertFacet'
function harness() {
const requests: any[] = []
const context = reactive({ planNoIn: 'chosen', planType: 'outbound', page: 2, sortField: 'date' })
const account = reactive({ token: 'a' })
const scope = effectScope()
const api = scope.run(() =>
useMobileCertFacet({
defs: [{ prop: 'planNo', type: 'enum', label: '计划编号' }],
context: () => context,
account: () => account.token,
fetch: (params: any) =>
new Promise((resolve, reject) => requests.push({ params, resolve, reject }))
})
)!
return { api, requests, context, account, scope }
}
const flush = async () => {
await Promise.resolve()
await nextTick()
}
test('remote keyword finds values outside first 500; strips self and paging; preserves selection and cancel', async () => {
const h = harness()
h.api.open('planNo', ['chosen'])
assert.equal(h.requests[0].params.planNoIn, undefined)
assert.equal(h.requests[0].params.page, undefined)
assert.equal(h.requests[0].params.sortField, undefined)
h.requests[0].resolve({ list: Array.from({ length: 500 }, (_, i) => ({ value: `${i}` })) })
await flush()
h.api.search('PO20261009022')
assert.equal(h.requests[1].params.keyword, 'PO20261009022')
h.requests[1].resolve({ data: { list: [{ value: 'PO20261009022' }] } })
await flush()
assert.equal(h.api.state.options[0].value, 'PO20261009022')
h.api.toggle('PO20261009022')
h.api.search('no result')
h.requests[2].resolve({ list: [] })
await flush()
assert.deepEqual(h.api.confirm(), { field: 'planNo', selected: ['chosen', 'PO20261009022'] })
h.api.open('planNo', ['chosen'])
h.api.toggle('changed')
h.api.close()
assert.equal(h.api.confirm(), null)
h.api.open('planNo', ['chosen'])
assert.deepEqual(h.api.state.selected, ['chosen'])
h.scope.stop()
})
test('reverse query success/error/finally cannot replace active request; retry is distinct from empty', async () => {
const h = harness()
h.api.open('planNo', [])
h.api.search('new')
h.requests[0].reject(new Error('stale'))
await flush()
assert.equal(h.api.state.error, '')
assert.equal(h.api.state.loading, true)
h.requests[1].reject(new Error('network'))
await flush()
assert.equal(h.api.state.error, 'network')
assert.equal(h.api.state.loading, false)
h.api.retry()
h.requests[2].resolve({ list: [] })
await flush()
assert.equal(h.api.state.error, '')
assert.deepEqual(h.api.state.options, [])
h.api.search('older')
h.api.search('latest')
h.requests[4].resolve({ list: [{ value: 'latest' }] })
await flush()
h.requests[3].resolve({ list: [{ value: 'older' }] })
await flush()
assert.equal(h.api.state.options[0].value, 'latest')
h.scope.stop()
})
test('close/reopen, context, account and disposal invalidate requests and confirmation', async () => {
const h = harness()
h.api.open('planNo', ['a'])
h.api.close()
h.api.open('planNo', ['b'])
h.requests[0].resolve({ list: [{ value: 'old' }] })
await flush()
assert.deepEqual(h.api.state.options, [])
assert.equal(h.api.state.loading, true)
h.context.planType = 'inbound'
assert.equal(h.api.confirm(), null)
h.requests[1].reject(new Error('stale'))
await flush()
assert.equal(h.api.state.error, '')
h.api.open('planNo', ['b'])
h.account.token = 'b'
assert.equal(h.api.confirm(), null)
h.api.open('planNo', ['c'])
h.scope.stop()
h.requests[3].resolve({ list: [{ value: 'after disposal' }] })
await flush()
assert.equal(h.api.confirm(), null)
assert.deepEqual(h.api.state.options, [])
})
// Execute the actual SFC setup with Vue reactivity; only transport/store/UI effects are substituted.
import { readFileSync } from 'node:fs'
import { parse } from '@vue/compiler-sfc'
import ts from 'typescript'
import * as vue from 'vue'
import { useServerColumnFilters } from '../src/composables/useServerColumnFilters'
import { plPlanCertColumnFilterDefs as defs } from '../src/views/pl/plan-cert/columnFilterDefs'
import {
withPlanContractIoFacetOptions,
PL_PLAN_CONTRACT_SIDE_FILTER_OPTIONS
} from '../src/utils/pl-plan-contract-display'
import {
applyMergedCustomerQueryParams,
applyContractSideQueryParams
} from '../src/utils/pl-plan-pair-list-query'
import { PL_PLAN_DEFAULT_FOLLOW_STATUSES, plPlanCertTabToFilters } from '../src/utils/pl-display'
import { buildQuery, facetPlanDateParams } from '../src/views/m/pl/plan-cert/model'
function component() {
const scope = effectScope()
const requests: any[] = [],
events: any[] = [],
warnings: string[] = []
const props = reactive({
modelValue: false,
filters: {} as any,
sort: { sortField: '', sortOrder: 'desc' },
context: {
planType: 'outbound',
certUploadStatus: 'incomplete',
planDateFrom: '2026-10-01',
followStatus: 'accepted'
} as any
})
const source = parse(
readFileSync(new URL('../src/views/m/pl/plan-cert/CertFilters.vue', import.meta.url), 'utf8')
).descriptor.scriptSetup!.content
const ast = ts.createSourceFile('setup.ts', source, ts.ScriptTarget.Latest, true)
const code = ts.transpile(
ast.statements
.filter((n) => !ts.isImportDeclaration(n))
.map((n) => n.getText(ast))
.join('\n'),
{ target: ts.ScriptTarget.ESNext }
)
const deps = {
ref: vue.ref,
computed: vue.computed,
watch: vue.watch,
defs,
useServerColumnFilters,
useMobileCertFacet,
withPlanContractIoFacetOptions,
PL_PLAN_CONTRACT_SIDE_FILTER_OPTIONS,
applyMergedCustomerQueryParams,
applyContractSideQueryParams,
PL_PLAN_DEFAULT_FOLLOW_STATUSES,
facetPlanDateParams,
useUserStore: () => reactive({ accessToken: 'a', getUserInfo: { tenantNo: 'a' } }),
fetchPlanColumnFacetValues: (params: any) =>
new Promise((resolve, reject) => requests.push({ params, resolve, reject })),
defineProps: () => props,
defineEmits:
() =>
(...args: any[]) =>
events.push(args),
ElMessage: { warning: (v: string) => warnings.push(v) }
}
const api = scope.run(() =>
new Function(
...Object.keys(deps),
`${code}; return {picker,draft,apply,resetAndApply,confirmPicker,optionLabel,pickerOptions}`
)(...Object.values(deps))
)
return { api, props, requests, events, warnings, scope }
}
test('component preserves query semantics and confirmation is isolated until accepted', async () => {
const h = component()
h.props.modelValue = true
await nextTick()
Object.assign(h.api.draft, {
followStatus: { selected: ['accepted'], text: '' },
planType: { selected: ['inbound'], text: '' },
hazardCertReady: { selected: ['1'], text: '' },
productContract: { selected: ['load', 'C-1'], text: '' }
})
h.api.picker.open('planNo', [])
const q = h.requests[0].params
assert.equal(q.planType, 'outbound')
assert.equal(q.hazardCertReady, 0)
assert.equal(q.hazardCertReadyIn, undefined)
assert.equal(q.followStatus, 'accepted')
assert.equal(q.planDateFrom, '2026-10-01')
assert.equal(q.stopKindIn, 'load')
h.api.picker.toggle('P-1')
assert.equal(h.api.draft.planNo, undefined)
h.api.confirmPicker()
assert.deepEqual(h.api.draft.planNo.selected, ['P-1'])
h.api.picker.open('productContract', ['load', 'C-1'])
h.api.picker.search('no match')
assert.equal(h.api.optionLabel('load'), '供')
assert.equal(h.api.optionLabel('C-1'), 'C-1')
h.scope.stop()
})
test('component date/number validation, normal apply, reset and closing discard picker', async () => {
const h = component()
h.props.modelValue = true
await nextTick()
h.api.draft.planDate = { from: '2026-10-02', to: '2026-10-01' }
h.api.apply()
assert.equal(h.events.length, 0)
assert.match(h.warnings[0], /开始不能晚于结束/)
delete h.api.draft.planDate
const number = defs.find((d) => d.type === 'number')!
h.api.draft[number.prop] = { op: 'between', min: '9', max: '2' }
h.api.apply()
assert.equal(h.events.length, 0)
assert.match(h.warnings[1], /最小值不能大于最大值/)
h.api.draft[number.prop] = { op: 'between', min: '2', max: '9' }
h.api.apply()
assert.equal(h.events[0][0], 'apply')
h.api.resetAndApply()
assert.deepEqual(h.events[2][1].followStatus.selected, PL_PLAN_DEFAULT_FOLLOW_STATUSES)
assert.equal(h.events[2][3], true)
h.api.picker.open('planNo', [])
h.props.modelValue = false
await nextTick()
assert.equal(h.api.picker.confirm(), null)
h.scope.stop()
})
test('component independent customer sides, contract-side stripping and draft date replacement use real query helpers', async () => {
const h = component()
h.props.modelValue = true
await nextTick()
Object.assign(h.api.draft, {
loadCustomer: { selected: ['装货甲'], text: '' },
deliveryCustomer: { selected: ['送货乙'], text: '' },
productContract: { selected: ['load', 'C-1'], text: '' },
planDate: { from: '2026-10-08', to: '2026-10-09' }
})
h.api.picker.open('planNo', [])
assert.equal(h.requests[0].params.loadCustomersIn, '装货甲')
assert.equal(h.requests[0].params.deliveryCustomersIn, '送货乙')
assert.equal(h.requests[0].params.stopCustomers, undefined)
assert.equal(h.requests[0].params.contractNoIn, 'C-1')
assert.equal(h.requests[0].params.planDateFrom, '2026-10-08')
h.api.picker.open('loadCustomer', [])
assert.equal(h.requests[1].params.stopCustomers, undefined)
h.api.picker.open('productContract', ['load', 'C-1'])
assert.equal(h.requests[2].params.stopKindIn, undefined)
assert.equal(h.requests[2].params.contractNoIn, undefined)
assert.deepEqual(
h.api.pickerOptions.value.map((o: any) => o.label),
['供', '需', '出库', '入库']
)
h.scope.stop()
})
test('confirmed contract summary retains side labels when another picker is active', async () => {
const h = component()
h.props.modelValue = true
await nextTick()
h.api.picker.open('productContract', ['load', 'delivery', 'C-1', '出库'])
h.api.confirmPicker()
h.api.picker.open('planNo', [])
const labels = h.api.draft.productContract.selected.map((value: string) =>
h.api.optionLabel(value, 'productContract')
)
assert.deepEqual(labels, ['供', '需', 'C-1', '出库'])
assert.equal(h.api.optionLabel('load', 'planNo'), 'load')
h.scope.stop()
})
import { Search as VanSearch } from 'vant'
test('actual Vant Search mounts a native clear button whose activation preserves picker and selection', async () => {
const h = component()
h.props.modelValue = true
await nextTick()
h.api.picker.open('planNo', ['kept'])
h.api.picker.search('PO20261021002')
const source = parse(
readFileSync(new URL('../src/views/m/pl/plan-cert/CertFilters.vue', import.meta.url), 'utf8')
).descriptor.template!.content
const searchTemplate = source.match(/<van-search\b[\s\S]*?(?:\/>|<\/van-search>)/)![0]
type Node = { type: string; props: any; children: Node[]; parent?: Node }
const renderer = vue.createRenderer<Node, Node>({
createElement: (type) => ({ type, props: {}, children: [] }),
createText: (text) => ({ type: '#text', props: { text }, children: [] }),
createComment: () => ({ type: '#comment', props: {}, children: [] }),
setText: (node, text) => {
node.props.text = text
},
setElementText: (node, text) => {
node.props.text = text
node.children = []
},
parentNode: (node) => node.parent || null,
nextSibling: () => null,
patchProp: (node, key, _old, value) => {
node.props[key] = value
},
insert: (node, parent) => {
node.parent = parent
parent.children.push(node)
},
remove: (node) => {
if (node.parent) node.parent.children = node.parent.children.filter((n) => n !== node)
}
})
const root: Node = { type: 'root', props: {}, children: [] }
const app = renderer.createApp({
components: { VanSearch },
setup: () => ({
picker: h.api.picker,
pickerState: h.api.picker.state,
pickerTitle: '计划编号'
}),
render: vue.compile(searchTemplate)
})
app.mount(root)
await nextTick()
const find = (node: Node): Node | undefined =>
node.type === 'button' && node.props['aria-label'] === '清空搜索'
? node
: node.children.map(find).find(Boolean)
const clear = find(root)
assert.ok(
clear,
'Search must expose a real button usable by mouse, touch-generated click and keyboard'
)
// Touch generates click too; both interaction paths use the same accessible native control.
for (const pointerType of ['mouse', 'touch', 'keyboard']) {
h.api.picker.search('PO20261021002')
await nextTick()
const activeClear = find(root)!
assert.ok(activeClear)
if (pointerType === 'mouse') {
let prevented = false
activeClear.props.onMousedown({
preventDefault() {
prevented = true
}
})
assert.equal(prevented, true)
}
activeClear.props.onClick({
type: 'click',
pointerType,
stopPropagation() {},
preventDefault() {}
})
await nextTick()
assert.equal(h.api.picker.state.keyword, '')
assert.equal(find(root), undefined)
assert.equal(h.api.picker.state.visible, true)
assert.deepEqual(h.api.picker.state.selected, ['kept'])
assert.equal(h.requests.at(-1).params.keyword, '')
}
app.unmount()
h.scope.stop()
})
test('contract search filters static IO choices and has a true empty state', async () => {
const h = component()
h.props.modelValue = true
await nextTick()
h.api.picker.open('productContract', ['load'])
h.api.picker.search('zz-no-contract')
h.requests.at(-1).resolve({ list: [] })
await nextTick()
await flush()
assert.deepEqual(h.api.pickerOptions.value, [])
assert.deepEqual(h.api.picker.state.selected, ['load'])
h.api.picker.search('入库')
assert.deepEqual(
h.api.pickerOptions.value.map((o: any) => o.label),
['入库']
)
h.scope.stop()
})
for (const tab of ['all', 'cert_incomplete', 'inbound', 'outbound']) {
test(`certificate ${tab} list query matches Web for independent customers and compound filters`, () => {
const { filters, toQueryParams } = useServerColumnFilters(defs)
Object.assign(filters, {
followStatus: { text: '', selected: ['accepted'] },
loadCustomer: { text: '', selected: ['装货甲'] },
deliveryCustomer: { text: '', selected: ['送货乙'] },
productContract: { text: '', selected: ['load', 'C-1'] },
hazardCertReady: { text: '', selected: ['1'] }
})
const sort = { sortField: 'planDate', sortOrder: 'desc' }
const env = {
filters,
toQueryParams,
sort,
buildQuery,
plPlanCertTabToFilters,
applyMergedCustomerQueryParams,
applyContractSideQueryParams,
tab: vue.ref(tab),
activePlanTypeTab: vue.ref(tab),
appliedKeyword: vue.ref(' 运输 '),
quickSearch: vue.ref(' 运输 '),
dates: vue.ref(['2026-10-01', '2026-10-24']),
dateRange: vue.ref(['2026-10-01', '2026-10-24']),
toSortQueryParams: () => sort
}
const evaluate = (path: string, name: string) => {
const ast = ts.createSourceFile(
'query.ts',
parse(readFileSync(new URL(path, import.meta.url), 'utf8')).descriptor.scriptSetup!.content,
ts.ScriptTarget.Latest,
true
)
const fn = ast.statements.find((n) => ts.isFunctionDeclaration(n) && n.name?.text === name)!
return new Function(
...Object.keys(env),
`${ts.transpile(fn.getText(ast), { target: ts.ScriptTarget.ESNext })};return ${name}()`
)(...Object.values(env))
}
const mobile = evaluate('../src/views/m/pl/plan-cert/index.vue', 'query')
assert.deepEqual(
mobile,
evaluate('../src/views/pl/plan-cert/index.vue', 'buildListQueryParams')
)
assert.equal(mobile.loadCustomersIn, '装货甲')
assert.equal(mobile.deliveryCustomersIn, '送货乙')
})
}