| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286 |
- 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')
- })
|