| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347 |
- 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()
- }
- })
|