348 lines
12 KiB
TypeScript
348 lines
12 KiB
TypeScript
import assert from 'node:assert/strict'
|
|
import { readFileSync } from 'node:fs'
|
|
import test from 'node:test'
|
|
import * as Vue from 'vue'
|
|
import { renderToString } from 'vue/server-renderer'
|
|
import { DatePicker } from 'vant'
|
|
import { compileScript, compileTemplate, parse } from '@vue/compiler-sfc'
|
|
import ts from 'typescript'
|
|
import * as columns from '../src/views/m/crm/customer/columns'
|
|
import * as model from '../src/views/m/crm/customer/model'
|
|
import * as definitions from '../src/views/crm/customer/columnFilterDefs'
|
|
|
|
function harness(query: Record<string, string> = {}) {
|
|
const messages: string[] = []
|
|
let liveApply: () => void = () => {}
|
|
const file = 'CustomerFilters.vue'
|
|
const { descriptor } = parse(
|
|
readFileSync(new URL(`../src/views/m/crm/customer/${file}`, import.meta.url), 'utf8')
|
|
)
|
|
const script = compileScript(descriptor, { id: file })
|
|
const template = compileTemplate({
|
|
id: file,
|
|
filename: file,
|
|
source: descriptor.template!.content,
|
|
compilerOptions: { bindingMetadata: script.bindings }
|
|
})
|
|
assert.deepEqual(template.errors, [])
|
|
const require = (name: string) =>
|
|
name === 'vue'
|
|
? { ...Vue, resolveComponent: (name: string) => name }
|
|
: name.includes('/dict')
|
|
? { useDictStore: () => ({ getOptions: () => [] }) }
|
|
: name.includes('columnFilterDefs')
|
|
? definitions
|
|
: name === './columns'
|
|
? columns
|
|
: name === './model'
|
|
? model
|
|
: name === '@vueuse/core'
|
|
? {
|
|
useDebounceFn: (callback: () => void) => {
|
|
liveApply = callback
|
|
return () => {}
|
|
}
|
|
}
|
|
: name === 'vant'
|
|
? { showFailToast: (message: string) => messages.push(message) }
|
|
: { default: 'MpStructSkeleton' }
|
|
function evaluate(source: string) {
|
|
const exports: any = {}
|
|
new Function(
|
|
'require',
|
|
'exports',
|
|
'ref',
|
|
'computed',
|
|
'watch',
|
|
'nextTick',
|
|
ts.transpile(source, { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ESNext })
|
|
)(require, exports, Vue.ref, Vue.computed, Vue.watch, Vue.nextTick)
|
|
return exports
|
|
}
|
|
const props = Vue.reactive({ modelValue: true, query, context: {} })
|
|
const emitted: any[] = []
|
|
const scope = Vue.effectScope()
|
|
const setup = scope.run(() =>
|
|
evaluate(script.content).default.setup(props, {
|
|
expose() {},
|
|
emit: (...args: any[]) => emitted.push(args)
|
|
})
|
|
)
|
|
setup.syncFromProps()
|
|
const render = evaluate(template.code).render
|
|
function nodes() {
|
|
const found: any[] = []
|
|
function visit(node: any) {
|
|
if (!node || typeof node !== 'object') return
|
|
if (Array.isArray(node)) return node.forEach(visit)
|
|
found.push(node)
|
|
if (Array.isArray(node.children)) visit(node.children)
|
|
else if (node.children && typeof node.children === 'object')
|
|
for (const slot of Object.values(node.children))
|
|
if (typeof slot === 'function') visit(slot())
|
|
}
|
|
visit(render({}, [], props, Vue.proxyRefs(setup), {}, {}))
|
|
return found
|
|
}
|
|
return {
|
|
setup,
|
|
emitted,
|
|
nodes,
|
|
props,
|
|
messages,
|
|
runLive: () => liveApply(),
|
|
stop: () => scope.stop()
|
|
}
|
|
}
|
|
|
|
test('every reversed numeric range blocks explicit and live application without losing the draft', async () => {
|
|
for (const def of definitions
|
|
.buildCustomerColumnFilterDefs(() => [])
|
|
.filter((d) => d.type === 'number')) {
|
|
const h = harness()
|
|
try {
|
|
await Vue.nextTick()
|
|
h.setup.draft.value[def.prop + 'Min'] = '10'
|
|
h.setup.draft.value[def.prop + 'Max'] = '2'
|
|
h.runLive()
|
|
h.setup.apply()
|
|
assert.deepEqual(h.emitted, [], def.prop + ': invalid range must not submit or close')
|
|
assert.equal(h.setup.draft.value[def.prop + 'Min'], '10')
|
|
assert.equal(h.setup.draft.value[def.prop + 'Max'], '2')
|
|
assert.equal(h.messages.at(-1), def.label + ':最小值不能大于最大值')
|
|
assert.ok(h.nodes().some((node) => node.type === 'p' && node.props?.role === 'alert'))
|
|
} finally {
|
|
h.stop()
|
|
}
|
|
}
|
|
})
|
|
|
|
test('every reversed date range blocks explicit and live application while retaining both dates', async () => {
|
|
for (const def of definitions
|
|
.buildCustomerColumnFilterDefs(() => [])
|
|
.filter((d) => d.type === 'datetime')) {
|
|
const h = harness()
|
|
try {
|
|
await Vue.nextTick()
|
|
const from = def.queryKeyFrom || def.prop + 'From'
|
|
const to = def.queryKeyTo || def.prop + 'To'
|
|
h.setup.draft.value[from] = '2026-10-24'
|
|
h.setup.draft.value[to] = '2026-10-23'
|
|
h.runLive()
|
|
h.setup.apply()
|
|
assert.deepEqual(h.emitted, [], def.prop + ': invalid range must not submit or close')
|
|
assert.equal(h.setup.draft.value[from], '2026-10-24')
|
|
assert.equal(h.setup.draft.value[to], '2026-10-23')
|
|
assert.equal(h.messages.at(-1), def.label + ':开始日期不能晚于结束日期')
|
|
} finally {
|
|
h.stop()
|
|
}
|
|
}
|
|
})
|
|
|
|
test('valid equal, single-sided, zero and decimal ranges remain usable; inactive bounds are ignored', async () => {
|
|
const h = harness()
|
|
try {
|
|
await Vue.nextTick()
|
|
for (const [min, max] of [
|
|
['', ''],
|
|
['0', ''],
|
|
['', '2'],
|
|
['0', '0'],
|
|
['0.1', '0.2'],
|
|
['-2', '-1']
|
|
]) {
|
|
h.emitted.length = 0
|
|
Object.assign(h.setup.draft.value, {
|
|
dealAmountOp: 'between',
|
|
dealAmountMin: min,
|
|
dealAmountMax: max
|
|
})
|
|
h.setup.apply()
|
|
assert.ok(
|
|
h.emitted.some(([event]) => event === 'apply'),
|
|
`${min}..${max}`
|
|
)
|
|
}
|
|
for (const op of ['eq', 'gt', 'gte', 'lt', 'lte']) {
|
|
h.emitted.length = 0
|
|
Object.assign(h.setup.draft.value, {
|
|
dealAmountOp: op,
|
|
dealAmount: '0',
|
|
dealAmountMin: '2',
|
|
dealAmountMax: '1'
|
|
})
|
|
h.setup.apply()
|
|
assert.deepEqual(h.emitted.find(([event]) => event === 'apply')[1].query, {
|
|
dealAmountOp: op,
|
|
dealAmount: '0'
|
|
})
|
|
}
|
|
for (const [from, to] of [
|
|
['', ''],
|
|
['2026-10-24', ''],
|
|
['', '2026-10-24'],
|
|
['2026-10-24', '2026-10-24']
|
|
]) {
|
|
h.emitted.length = 0
|
|
Object.assign(h.setup.draft.value, { createTimeFrom: from, createTimeTo: to })
|
|
h.setup.apply()
|
|
assert.ok(
|
|
h.emitted.some(([event]) => event === 'apply'),
|
|
`${from}..${to}`
|
|
)
|
|
}
|
|
assert.deepEqual(h.messages, [])
|
|
} finally {
|
|
h.stop()
|
|
}
|
|
})
|
|
|
|
test('correcting a reversed range clears its message and resumes live filtering; closed panels stay inactive', async () => {
|
|
const h = harness()
|
|
try {
|
|
await Vue.nextTick()
|
|
Object.assign(h.setup.draft.value, { followCountMin: '2', followCountMax: '1', mobile: '199' })
|
|
h.setup.apply()
|
|
assert.deepEqual(h.emitted, [])
|
|
h.setup.draft.value.followCountMax = '2'
|
|
h.runLive()
|
|
assert.deepEqual(h.emitted.find(([event]) => event === 'apply')[1].query, {
|
|
mobile: '199',
|
|
followCountOp: 'between',
|
|
followCountMin: '2',
|
|
followCountMax: '2'
|
|
})
|
|
assert.ok(!h.nodes().some((node) => node.type === 'p' && node.props?.role === 'alert'))
|
|
h.emitted.length = 0
|
|
h.props.modelValue = false
|
|
h.runLive()
|
|
assert.deepEqual(h.emitted, [])
|
|
} finally {
|
|
h.stop()
|
|
}
|
|
})
|
|
|
|
test('date range selection sends Web date-only inclusive boundaries, without time controls', () => {
|
|
const h = harness()
|
|
try {
|
|
h.setup.openDateTime('nextTimeFrom')
|
|
h.setup.dateValue.value = ['2026', '10', '23']
|
|
h.setup.onDateConfirm()
|
|
assert.equal(h.setup.draft.value.nextTimeFrom, '2026-10-23')
|
|
h.setup.openDateTime('nextTimeTo')
|
|
h.setup.dateValue.value = ['2026', '10', '23']
|
|
h.setup.onDateConfirm()
|
|
assert.equal(h.nodes().filter((n) => n.type === 'van-time-picker').length, 0)
|
|
assert.deepEqual(h.setup.buildQuery(), { nextTimeFrom: '2026-10-23', nextTimeTo: '2026-10-23' })
|
|
h.setup.apply()
|
|
assert.deepEqual(h.emitted.find(([event]) => event === 'apply')[1].query, {
|
|
nextTimeFrom: '2026-10-23',
|
|
nextTimeTo: '2026-10-23'
|
|
})
|
|
} finally {
|
|
h.stop()
|
|
}
|
|
})
|
|
|
|
test('hydrated legacy date-times normalize to date-only for every defined date boundary', () => {
|
|
const defs = definitions
|
|
.buildCustomerColumnFilterDefs(() => [])
|
|
.filter((d) => d.type === 'datetime')
|
|
const query = Object.fromEntries(
|
|
defs
|
|
.flatMap((d) => [d.queryKeyFrom || d.prop + 'From', d.queryKeyTo || d.prop + 'To'])
|
|
.map((key) => [key, '1985-02-03 12:34:56'])
|
|
)
|
|
const h = harness(query)
|
|
try {
|
|
for (const key of Object.keys(query)) {
|
|
assert.equal(h.setup.draft.value[key], '1985-02-03')
|
|
assert.equal(h.setup.buildQuery()[key], '1985-02-03')
|
|
}
|
|
assert.equal(h.setup.displayDateTime('1985-02-03T12:34:56'), '1985-02-03')
|
|
} finally {
|
|
h.stop()
|
|
}
|
|
})
|
|
|
|
test('each date boundary exposes a clear button that preserves its opposite and other filters', () => {
|
|
const h = harness({ nextTimeFrom: '2026-10-01', nextTimeTo: '2026-10-23', mobile: '138' })
|
|
try {
|
|
for (const suffix of ['From', 'To']) {
|
|
const label = suffix === 'From' ? '清空下次联系时间起始日期' : '清空下次联系时间截止日期'
|
|
const button = h
|
|
.nodes()
|
|
.find((n) => n.type === 'van-button' && n.props?.['aria-label'] === label)
|
|
assert.ok(button, label)
|
|
button.props.onClick({ stopPropagation() {} })
|
|
assert.equal(h.setup.buildQuery()['nextTime' + suffix], undefined)
|
|
assert.equal(h.setup.buildQuery().mobile, '138')
|
|
assert.equal(h.setup.dateOpen.value, false)
|
|
if (suffix === 'From') assert.equal(h.setup.buildQuery().nextTimeTo, '2026-10-23')
|
|
}
|
|
} finally {
|
|
h.stop()
|
|
}
|
|
})
|
|
|
|
test('historical and distant filter dates are reachable with bounded real Vant rendering', async () => {
|
|
const h = harness({ nextTimeFrom: '1850-03-02' })
|
|
try {
|
|
h.setup.openDateTime('nextTimeFrom')
|
|
assert.deepEqual(h.setup.dateValue.value, ['1850', '03', '02'])
|
|
for (const year of ['1000', '1850', '2101', '9999']) {
|
|
const input = h.nodes().find((n) => n.type === 'van-field' && n.props.label === '年份')
|
|
assert.ok(input, 'full year range available via year input')
|
|
input.props['onUpdate:modelValue'](year)
|
|
input.props.onBlur()
|
|
await Vue.nextTick()
|
|
const picker = h.nodes().find((n) => n.type === 'van-date-picker')
|
|
const html = await renderToString(
|
|
Vue.createSSRApp({
|
|
render: () =>
|
|
Vue.h(DatePicker, {
|
|
modelValue: h.setup.dateValue.value,
|
|
minDate: picker.props['min-date'],
|
|
maxDate: picker.props['max-date']
|
|
})
|
|
})
|
|
)
|
|
const count = (html.match(/<li\b/g) || []).length
|
|
assert.ok(count > 0 && count <= 64, `actual Vant option count: ${count}`)
|
|
h.setup.onDateConfirm()
|
|
assert.equal(h.setup.buildQuery().nextTimeFrom, `${year}-03-02`)
|
|
h.setup.openDateTime('nextTimeFrom')
|
|
}
|
|
} finally {
|
|
h.stop()
|
|
}
|
|
})
|
|
|
|
test('cancel leaves the range intact; year edits handle leap days and incomplete input', async () => {
|
|
const h = harness({ nextTimeFrom: '2000-02-29', nextTimeTo: '2026-10-23' })
|
|
try {
|
|
h.setup.openDateTime('nextTimeFrom')
|
|
h.setup.yearInput.value = '1900'
|
|
h.setup.applyYear()
|
|
await Vue.nextTick()
|
|
assert.deepEqual(h.setup.dateValue.value, ['1900', '02', '28'])
|
|
h.nodes()
|
|
.find((n) => n.type === 'van-date-picker')
|
|
.props.onCancel()
|
|
assert.equal(h.setup.buildQuery().nextTimeFrom, '2000-02-29')
|
|
h.setup.openDateTime('nextTimeFrom')
|
|
assert.deepEqual(h.setup.dateValue.value, ['2000', '02', '29'])
|
|
for (const input of ['', '999', '10000', 'abcd']) {
|
|
h.setup.yearInput.value = input
|
|
h.setup.applyYear()
|
|
assert.equal(h.setup.yearInput.value, '2000')
|
|
assert.deepEqual(h.setup.dateValue.value, ['2000', '02', '29'])
|
|
}
|
|
h.setup.onDateConfirm()
|
|
assert.deepEqual(h.setup.buildQuery(), { nextTimeFrom: '2000-02-29', nextTimeTo: '2026-10-23' })
|
|
} finally {
|
|
h.stop()
|
|
}
|
|
})
|