Files
shanxia2.0/web-admin/scripts/mobile-customer-field-controls.test.ts
T

287 lines
11 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 model from '../src/views/m/crm/customer/model'
import * as invoiceParser from '../src/utils/crm-invoice-paste-recognize'
function harness(file = 'CustomerFields.vue', values: Record<string, any> = {}, readonly = false) {
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 runtime = { ...Vue, resolveComponent: (name: string) => name }
const require = (name: string) =>
name === 'vue'
? runtime
: name.includes('/dict')
? {
useDictStore: () => ({
getOptions: () => [{ value: 'normal', label: '普通客户' }],
getTagType: () => 'success'
})
}
: name.includes('invoice-paste')
? invoiceParser
: name === './model'
? model
: { default: 'CustomerFields' }
function evaluate(source: string) {
const exports: any = {}
new Function(
'require',
'exports',
'ref',
'computed',
'watch',
ts.transpile(source, { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ESNext })
)(require, exports, Vue.ref, Vue.computed, Vue.watch)
return exports
}
const props = Vue.reactive({
fields: model.customerGroups.flatMap((g) => g.fields),
modelValue: values,
readonly,
plainText: false,
users: [
{ value: '1', label: '张三' },
{ value: '2', label: '李四' }
]
})
const emitted: any[] = []
const setup = evaluate(script.content).default.setup(props, {
expose() {},
emit: (_: string, value: any) => {
emitted.push(value)
props.modelValue = value
}
})
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, props, emitted, nodes }
}
const owner = model.customerGroups[2].fields[0]
const date = model.customerGroups[2].fields[1]
test('owner picker search filters labels, retains clear option and resets on cancel/reopen', async () => {
const h = harness()
h.setup.openSelect(owner)
const search = h.nodes().find((n) => n.type === 'van-search')
assert.ok(search, 'picker exposes a search control')
search.props['onUpdate:modelValue']('张')
assert.deepEqual(
h.setup.selectColumns.value.map((c: any) => c.value),
['', '1']
)
h.nodes()
.find((n) => n.type === 'van-picker')
.props.onCancel()
await Vue.nextTick()
assert.equal(h.setup.selectSearch.value, '')
h.setup.openSelect(owner)
assert.deepEqual(
h.setup.selectColumns.value.map((c: any) => c.value),
['', '1', '2']
)
h.setup.onSelectConfirm({ selectedValues: ['2'] })
assert.equal(h.props.modelValue.ownerUserId, '2')
h.setup.openSelect(owner)
h.setup.onSelectConfirm({ selectedValues: [''] })
assert.equal(h.props.modelValue.ownerUserId, '')
})
test('datetime retains seconds and old dates, picker exposes seconds and full date range', () => {
const h = harness('CustomerFields.vue', { nextTime: '1990-03-02 10:20:37' })
h.setup.openDate(date)
assert.deepEqual(h.setup.timeParts.value, ['10', '20', '37'])
assert.equal(h.setup.displayDate(date), '1990-03-02 10:20:37')
const nodes = h.nodes()
assert.deepEqual(nodes.find((n) => n.type === 'van-time-picker').props['columns-type'], [
'hour',
'minute',
'second'
])
const picker = nodes.find((n) => n.type === 'van-date-picker')
assert.ok(picker.props['min-date'].getFullYear() <= 1990)
assert.ok(picker.props['max-date'].getFullYear() >= 1990)
h.setup.onDateConfirm()
assert.equal(model.apiTime(h.props.modelValue.nextTime), '1990-03-02 10:20:37')
})
test('date clear button clears only its field without reopening picker', () => {
const h = harness('CustomerFields.vue', { nextTime: '2026-10-23 10:20:37', remark: '保留' })
const button = h
.nodes()
.find((n) => n.type === 'van-button' && n.props?.['aria-label'] === '清空下次联系时间')
assert.ok(button, 'filled date exposes a clear action')
button.props.onClick({ stopPropagation() {} })
assert.equal(h.props.modelValue.nextTime, '')
assert.equal(h.props.modelValue.remark, '保留')
assert.equal(h.setup.dateOpen.value, false)
})
test('readonly dictionary and next contact time use semantic mobile tags; missing values stay empty', () => {
const h = harness(
'CustomerFields.vue',
{ level: 'normal', nextTime: '2000-01-01 00:00:00' },
true
)
const spans = h.nodes().filter((n) => n.type === 'span')
assert.ok(
spans.some((n) => n.props.class.includes('status-tag--success') && n.children === '普通客户')
)
assert.ok(
spans.some((n) => n.props.class.includes('status-tag--danger') && n.children.includes('2000'))
)
h.props.plainText = true
assert.equal(
h.nodes().filter((n) => n.type === 'span' && n.props?.class?.includes('status-tag')).length,
0
)
assert.ok(
h.nodes().some((n) => n.type === 'van-cell' && n.props.value === '普通客户'),
'plain text mode preserves dictionary labels'
)
h.props.modelValue = { level: '', nextTime: '' }
assert.equal(
h.nodes().filter((n) => n.type === 'span' && n.props?.class?.includes('status-tag')).length,
0
)
assert.equal(h.setup.readonlyText(date), '—')
})
test('clear invoice paste removes input and recognition feedback without clearing filled fields', () => {
const h = harness('InvoiceFields.vue', { companyName: '已填写公司', remark: '保留' })
h.setup.paste.value = '名称:识别公司'
h.setup.recognize()
const before = { ...h.props.modelValue }
const clear = h
.nodes()
.find(
(n) =>
n.type === 'van-button' &&
n.children?.some((v: any) =>
typeof v === 'string' ? v.includes('清空') : v.children?.includes('清空')
)
)
assert.ok(clear, 'paste has a clear button')
clear.props.onClick()
assert.equal(h.setup.paste.value, '')
assert.equal(h.setup.hint.value, '')
assert.deepEqual(h.props.modelValue, before)
assert.equal(h.emitted.length, 1)
})
test('invoice recognition reports auxiliary fields merged into remarks as Web does', () => {
const h = harness('InvoiceFields.vue', { remark: '原备注' })
h.setup.paste.value = '银行行号:105000000000\n邮编:000000'
h.setup.recognize()
assert.equal(h.props.modelValue.remark, '原备注;银行行号:105000000000;邮编:000000')
assert.equal(
h.setup.hint.value,
'已识别 0 个字段,另有 2 项写入备注;未识别到单位名称;未识别到税号;未能识别任何开票字段,请检查格式或手动填写'
)
h.setup.recognize()
assert.equal(h.props.modelValue.remark, '原备注;银行行号:105000000000;邮编:000000')
})
test('today and future contact times follow Web warning semantics', (t) => {
t.mock.timers.enable({ apis: ['Date'], now: new Date('2026-10-23T00:00:00Z') })
const h = harness('CustomerFields.vue', { nextTime: '2026-10-23 23:59:59' }, true)
assert.equal(h.setup.readonlyTagType(date), 'warning')
h.props.modelValue = { nextTime: '2027-01-01 10:00:00' }
assert.equal(h.setup.readonlyTagType(date), undefined)
})
test('date-only confirmation and cancellation preserve existing values correctly', () => {
const birthday = { key: 'birthday', label: '生日', type: 'date' }
const h = harness('CustomerFields.vue', {
birthday: '1985-02-03',
nextTime: '2001-01-02 03:04:55'
})
h.setup.openDate(birthday)
const picker = h.nodes().find((n) => n.type === 'van-date-picker')
picker.props.onCancel()
assert.equal(h.emitted.length, 0)
assert.equal(h.props.modelValue.birthday, '1985-02-03')
h.setup.openDate(birthday)
h.setup.onDateOnlyConfirm()
assert.equal(h.props.modelValue.birthday, '1985-02-03')
assert.equal(h.props.modelValue.nextTime, '2001-01-02 03:04:55')
})
test('real Vant date picker renders bounded options while every business year remains reachable', async () => {
const h = harness('CustomerFields.vue', { nextTime: '1990-03-02 10:20:37' })
h.setup.openDate(date)
async function assertBounded() {
const picker = h.nodes().find((n) => n.type === 'van-date-picker')
const html = await renderToString(
Vue.createSSRApp({
render: () =>
Vue.h(DatePicker, {
modelValue: h.setup.dateParts.value,
minDate: picker.props['min-date'],
maxDate: picker.props['max-date']
})
})
)
const count = (html.match(/<li\b/g) || []).length
assert.ok(count > 0, 'count actual Vant option nodes')
assert.ok(count <= 64, `bounded year/month/day options, received ${count}`)
}
await assertBounded()
for (const year of ['1000', '1850', '2101', '9999']) {
const yearInput = h.nodes().find((n) => n.type === 'van-field' && n.props.label === '年份')
assert.ok(yearInput, 'direct year input reaches dates outside the wheel window')
yearInput.props['onUpdate:modelValue'](year)
yearInput.props.onBlur()
await Vue.nextTick()
assert.equal(h.setup.dateParts.value[0], year)
await assertBounded()
h.setup.onDateConfirm()
assert.equal(model.apiTime(h.props.modelValue.nextTime), `${year}-03-02 10:20:37`)
h.setup.openDate(date)
}
})
test('year input clamps leap day and ignores incomplete years without corrupting the date', async () => {
const h = harness('CustomerFields.vue', { nextTime: '2000-02-29 10:20:37' })
h.setup.openDate(date)
h.setup.yearInput.value = '1900'
h.setup.applyYear()
await Vue.nextTick()
assert.deepEqual(h.setup.dateParts.value, ['1900', '02', '28'])
for (const input of ['', '999', '10000', 'abcd']) {
h.setup.yearInput.value = input
h.setup.applyYear()
assert.equal(h.setup.yearInput.value, '1900')
assert.deepEqual(h.setup.dateParts.value, ['1900', '02', '28'])
}
h.setup.onDateConfirm()
assert.equal(model.apiTime(h.props.modelValue.nextTime), '1900-02-28 10:20:37')
})