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 = {}, 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(/ 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') })