287 lines
11 KiB
TypeScript
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')
|
|
})
|