433 lines
16 KiB
TypeScript
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, '送货乙')
|
|
})
|
|
}
|