import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { createRequire } from 'node:module' import test from 'node:test' import { computed, ref } from 'vue' import { compileScript, compileTemplate, parse } from '@vue/compiler-sfc' import ts from 'typescript' import * as model from '../src/views/m/crm/customer/model' import * as validation from '../src/views/m/crm/customer/validation' const base = new URL('../src/views/m/crm/customer/', import.meta.url) test('customer and contact native submissions reach the same explicit validation as save buttons', () => { for (const file of ['form.vue', 'ContactPanel.vue']) { const { descriptor } = parse(readFileSync(new URL(file, base), 'utf8')) assert.match( descriptor.template!.content, /]*\bnovalidate\b[^>]*@submit\.prevent="save"/ ) } }) // Execute the current production declarations; stub only transport, navigation and UI feedback. function execute(file: string, names: string[], bindings: Record) { // Validation fixtures keep one record/session; identity transitions have renderer coverage separately. bindings = { identity: { begin: () => ({ id: String(bindings.props?.customerId || bindings.props?.opportunity?.id || ''), valid: () => true }) }, ...validation, ...bindings } const { descriptor } = parse(readFileSync(new URL(file, base), 'utf8')) const ast = ts.createSourceFile( file, descriptor.scriptSetup!.content, ts.ScriptTarget.Latest, true ) const selected = [...names, 'showSaveError'] const source = ast.statements .filter((node) => ts.isFunctionDeclaration(node) ? selected.includes(node.name?.text || '') : ts.isVariableStatement(node) && selected.includes(node.declarationList.declarations[0].name.getText(ast)) ) .map((node) => node.getText(ast)) .join('\n') return new Function( ...Object.keys(bindings), `${ts.transpile(source, { target: ts.ScriptTarget.ESNext })}\nreturn { ${names.join(',')} }` )(...Object.values(bindings)) } function feedback() { const notices: string[] = [] const events: string[] = [] return { notices, events, showFailToast: (message: string) => notices.push(message), emit: (event: string) => events.push(event) } } function contactHarness(edit: boolean, patch: model.Row = {}, write = async () => {}) { const payloads: model.Row[] = [] const routes: string[] = [] let reloads = 0 const bindings = { ...model, ...feedback(), acting: ref(false), saveError: ref(''), editing: ref(true), form: ref({ contactName: '测试联系人', mobile: '13800138000', telephone: '前台转 123 / 工作日', email: '', isKey: 1, isPrimary: 0, remark: '保留草稿', ...(edit ? { id: 'contact-1' } : {}), ...patch }), props: { customerId: 'customer-1' }, fetchCreateContact: async (data: model.Row) => { payloads.push(data) routes.push('create') await write() }, fetchUpdateContact: async (data: model.Row) => { payloads.push(data) routes.push('update') await write() }, load: async () => { reloads++ } } return { ...bindings, ...execute('ContactPanel.vue', ['save'], bindings), payloads, routes, reloads: () => reloads } } for (const edit of [false, true]) { for (const [key, value, message] of [ ['contactName', ' ', '联系人姓名'], ['mobile', '', '手机号'], ['mobile', '123', '手机号'], ['mobile', '12800138000', '手机号'], ['mobile', '138001380000', '手机号'], ['email', 'bad', '邮箱'], ['email', 'a@b', '邮箱'] ]) { test(`contact ${edit ? 'edit' : 'create'} rejects ${key}=${value} before transport`, async () => { const h = contactHarness(edit, { [key]: value }) const before = { ...h.form.value } await h.save() assert.equal(h.payloads.length, 0) assert.match(h.saveError.value, new RegExp(message)) assert.equal(h.notices.at(-1), h.saveError.value) assert.deepEqual(h.form.value, before) assert.equal(h.editing.value, true) assert.equal(h.acting.value, false) assert.equal(h.reloads(), 0) assert.deepEqual(h.events, []) }) } for (const email of ['', null, undefined, 'a@example.com', 'first.last+tag@sub.example.co.uk']) { test(`contact ${edit ? 'edit' : 'create'} accepts optional email ${email} and free-form telephone`, async () => { const h = contactHarness(edit, { email }) await h.save() assert.deepEqual(h.routes, [edit ? 'update' : 'create']) assert.equal(h.payloads[0].telephone, '前台转 123 / 工作日') assert.equal(h.payloads[0].email, email ?? '') assert.equal(h.payloads[0].customerId, 'customer-1') assert.equal(h.payloads[0].isKey, 1) assert.equal(h.payloads[0].id, edit ? 'contact-1' : undefined) assert.equal(h.editing.value, false) assert.equal(h.reloads(), 1) assert.deepEqual(h.events, ['changed']) }) } } function customerHarness(edit = false, patch: model.Row = {}, actions: model.Row = {}) { const calls: string[] = [] const payloads: model.Row[] = [] const navigations: string[] = [] const bindings = { ...model, ...feedback(), props: { embedded: false }, saving: ref(false), error: ref(''), uploads: new Map(), id: ref(edit ? 'customer-1' : ''), state: { customerId: '' }, invoice: ref({}), form: ref( model.customerForm({ ...(edit ? { id: 'customer-1' } : {}), customerName: '测试客户', shortName: '测试', customerType: 'type', source: 'source', parkArea: '新园区', province: '省', city: '市', district: '区', mobile: '', email: '', telephone: '办公总机 / 分机 123', remark: '保留草稿', ...patch }) ), dict: { getOptions: () => [], ensure: async () => { calls.push('ensure') return { value: '新园区' } } }, showCrmConfirmDialog: async () => { calls.push('confirm') }, fetchCheckDuplicate: async (data: model.Row) => { calls.push('duplicate') return actions.duplicate ? actions.duplicate(data) : { isDuplicate: false } }, fetchCreateCustomer: async (data: model.Row) => { calls.push('create') payloads.push(data) if (actions.write) await actions.write() return { id: 'created-customer' } }, fetchUpdateCustomer: async (data: model.Row) => { calls.push('update') payloads.push(data) if (actions.write) await actions.write() }, addInvoiceInfo: async (data: model.Row) => { calls.push('invoice') payloads.push(data) if (actions.invoice) await actions.invoice() }, showSuccessToast: () => {}, router: { replace: async (path: string) => { navigations.push(path) } } } return { ...bindings, ...execute('form.vue', ['save'], bindings), calls, payloads, navigations } } test('embedded customer creation and editing emit success without leaving the quote', async () => { for (const edit of [false, true]) { const h = customerHarness(edit) h.props.embedded = true await h.save() assert.equal(h.error.value, '') assert.deepEqual(h.events, ['saved']) assert.deepEqual(h.navigations, []) assert.ok(h.calls.includes(edit ? 'update' : 'create')) } }) test('production customer and contact email validation agrees with the Web async-validator public API', async () => { const require = createRequire(import.meta.url) const requireFromElement = createRequire(require.resolve('element-plus/package.json')) const Schema = requireFromElement('async-validator').default const schema = new Schema({ email: { type: 'email' } }) for (const email of [ '', null, undefined, 'bad', 'a@b', 'a@example.com', 'first.last+tag@sub.example.co.uk', 'a..b@example.com', '.a@example.com', 'a@example.c', ' a@example.com ', ' ', '"quoted name"@example.com', 'user@[192.168.1.1]', '名字@例子.中国', 'a@ex-ample.com', 'a@foo_bar.com', 'a@domain.123', 123, 'a'.repeat(307) + '@example.com', 'a'.repeat(309) + '@example.com' ]) { const valid = await schema.validate({ email }, { suppressWarning: true }).then( () => true, () => false ) for (const h of [contactHarness(false, { email }), customerHarness(false, { email })]) { await h.save() assert.equal(h.payloads.length, valid ? 1 : 0, `email=${String(email)}`) if (!valid) assert.match(h.notices.at(-1) || '', /邮箱/) } } }) for (const edit of [false, true]) { const invalid = [ ...model.customerGroups .flatMap((g) => g.fields) .filter((f) => f.required) .map((f) => [f.key, ' ', f.label]), ['mobile', '123', '手机号'], ['email', 'bad', '邮箱'], ['email', 'a@b', '邮箱'] ] for (const [key, value, label] of invalid) { test(`customer ${edit ? 'edit' : 'create'} rejects ${key} before dictionary, duplicate check or write`, async () => { const h = customerHarness(edit, { [key]: value }) const before = { ...h.form.value } await h.save() assert.deepEqual(h.calls, []) assert.match(h.error.value, new RegExp(label)) assert.equal(h.notices.at(-1), h.error.value) assert.deepEqual(h.form.value, before) assert.equal(h.saving.value, false) assert.deepEqual(h.navigations, []) }) } for (const patch of [ { mobile: '', email: '' }, { mobile: null, email: undefined }, { mobile: '19900138000', email: 'a@example.com' } ]) { test(`customer ${edit ? 'edit' : 'create'} accepts optional contact details ${JSON.stringify(patch)}`, async () => { const h = customerHarness(edit, patch) await h.save() assert.deepEqual(h.calls, ['confirm', 'ensure', 'duplicate', edit ? 'update' : 'create']) assert.equal(h.payloads[0].telephone, '办公总机 / 分机 123') assert.equal(h.navigations.length, 1) assert.equal(h.error.value, '') }) } } test('customer invoice failure retries only the invoice, even if the locked customer data is now invalid', async () => { let fail = true const h = customerHarness( false, {}, { invoice: async () => { if (fail) throw new Error('开票保存失败') } } ) h.invoice.value = { companyName: '测试单位', bankAccount: '自由账号' } await h.save() assert.equal(h.state.customerId, 'created-customer') assert.equal(h.error.value, '开票保存失败') assert.equal(h.notices.at(-1), h.error.value) assert.deepEqual(h.navigations, []) h.form.value.shortName = '' h.form.value.email = 'bad' fail = false await h.save() assert.deepEqual(h.calls, ['confirm', 'ensure', 'duplicate', 'create', 'invoice', 'invoice']) assert.equal(h.payloads.at(-1)?.customerId, 'created-customer') assert.equal(h.payloads.at(-1)?.isDefault, '1') assert.equal(h.navigations.length, 1) }) test('customer duplicate detection preserves the draft and reports a visible reason', async () => { const h = customerHarness(false, {}, { duplicate: async () => ({ isDuplicate: true }) }) const before = { ...h.form.value } await h.save() assert.deepEqual(h.calls, ['confirm', 'ensure', 'duplicate']) assert.equal(h.payloads.length, 0) assert.match(h.error.value, /客户名称已存在/) assert.equal(h.notices.at(-1), h.error.value) assert.deepEqual(h.form.value, before) assert.equal(h.saving.value, false) }) test('a failed duplicate check performs no customer write and permits retry', async () => { let fail = true const h = customerHarness( false, {}, { duplicate: async () => { if (fail) throw new Error('查重失败,请重试') return { isDuplicate: false } } } ) const before = { ...h.form.value } await h.save() assert.equal(h.payloads.length, 0) assert.equal(h.error.value, '查重失败,请重试') assert.equal(h.notices.at(-1), h.error.value) assert.deepEqual(h.form.value, before) assert.equal(h.saving.value, false) fail = false await h.save() assert.equal(h.payloads.length, 1) assert.equal(h.navigations.length, 1) }) test('optional invoice company name is still validated before both initial save and invoice retry', async () => { for (const customerId of ['', 'created-customer']) { const h = customerHarness() h.state.customerId = customerId h.invoice.value = { companyName: ' ', bankAccount: '已有账号' } await h.save() assert.deepEqual(h.calls, []) assert.match(h.error.value, /单位名称不能为空/) assert.equal(h.notices.at(-1), h.error.value) assert.equal(h.state.customerId, customerId) assert.equal(h.invoice.value.bankAccount, '已有账号') } }) test('customer attachment upload blocks all requests with visible feedback', async () => { const h = customerHarness() h.uploads.set('businessLicenseImages', { hasUploading: () => true }) await h.save() assert.deepEqual(h.calls, []) assert.equal(h.error.value, '附件上传中,请稍候') assert.equal(h.notices.at(-1), h.error.value) }) function opportunityHarness(modeName: string, patch: model.Row = {}, write = async () => {}) { const payloads: model.Row[] = [] const routes: string[] = [] const capture = (route: string) => async (data: model.Row) => { routes.push(route) payloads.push(data) await write() } const bindings = { ...model, ...feedback(), computed, ref, mode: ref(modeName), busy: ref(false), error: ref(''), upload: ref(null), customers: ref([]), form: ref({ opportunityName: '测试商机', customerId: 'customer-1', stage: 'quote', stagePercent: 60, winRate: 65, ownerUserId: 'owner-1', changeReason: '推进原因', followType: 'phone', followTime: '2026-10-22T09:30', followContent: '联系内容', nextTime: '', nextPlan: '下次计划', attachments: 'fixture.pdf', remark: '保留备注', createTime: '只读字段', status: 'following', contactId: '不应覆盖关联联系人', amount: 999, ...patch }), dictOptions: () => [], OPPORTUNITY_TYPE_FALLBACK: {}, OPPORTUNITY_SOURCE_FALLBACK: {}, props: { opportunity: { id: 'opportunity-1', stage: 'initial', contactId: 'contact-1', amount: 100 } }, changeStage: capture('stage'), updateOpportunity: capture('edit'), addOpportunityFollow: capture('follow-create'), updateOpportunityFollow: capture('follow-edit') } return { ...bindings, ...execute('OpportunityActions.vue', ['progress', 'fields', 'save'], bindings), payloads, routes } } for (const mode of ['edit', 'stage']) { for (const key of ['stagePercent', 'winRate']) { for (const value of [-1, 101, NaN, Infinity, -Infinity, 'not-a-number']) { test(`opportunity ${mode} rejects ${key}=${value} at the save entry`, async () => { const h = opportunityHarness(mode, { [key]: value }) const before = { ...h.form.value } await h.save() assert.equal(h.payloads.length, 0) assert.match(h.error.value, key === 'winRate' ? /赢单率/ : /阶段进度/) assert.equal(h.notices.at(-1), h.error.value) assert.deepEqual(h.form.value, before) assert.equal(h.mode.value, mode) assert.equal(h.busy.value, false) }) } } for (const value of [0, 7, 100, '0', '100']) { test(`opportunity ${mode} accepts ${value} without a multiple-of-five restriction`, async () => { const h = opportunityHarness(mode, { stagePercent: value, winRate: value }) await h.save() assert.deepEqual(h.routes, [mode]) assert.equal(h.payloads[0].stagePercent, Number(value)) assert.equal(h.payloads[0].winRate, Number(value)) assert.equal(h.mode.value, '') }) } } for (const [mode, input, expected] of [ ['edit', 12.345, 12.35], ['edit', 1.005, 1.01], ['edit', 4.015, 4.02], ['edit', 99.995, 100], ['edit', 12.3449, 12.34], ['stage', 7.5, 8], ['stage', 7.4, 7] ] as const) { test(`opportunity ${mode} follows Web input precision for ${input}`, async () => { const h = opportunityHarness(mode, { stagePercent: input, winRate: input }) await h.save() assert.equal(h.payloads[0].winRate, expected) assert.equal(h.payloads[0].stagePercent, mode === 'edit' ? input : expected) assert.equal(h.form.value.winRate, input) }) } test('opportunity edit retains hidden writable associations and excludes readonly fields', async () => { const h = opportunityHarness('edit') await h.save() assert.equal(h.payloads[0].contactId, 'contact-1') assert.equal(h.payloads[0].amount, 100) assert.equal(h.payloads[0].id, 'opportunity-1') assert.equal(h.payloads[0].remark, '保留备注') assert.equal(Object.hasOwn(h.payloads[0], 'status'), false) assert.equal(Object.hasOwn(h.payloads[0], 'createTime'), false) }) test('opportunity stage rejects unchanged stage and preserves the draft', async () => { const h = opportunityHarness('stage', { stage: 'initial' }) await h.save() assert.equal(h.payloads.length, 0) assert.match(h.error.value, /相同/) assert.equal(h.notices.at(-1), h.error.value) assert.equal(h.mode.value, 'stage') }) test('opportunity stage keeps its narrow payload', async () => { const h = opportunityHarness('stage') await h.save() assert.deepEqual(h.payloads[0], { id: 'opportunity-1', stage: 'quote', stagePercent: 60, winRate: 65, changeReason: '推进原因', remark: '保留备注' }) }) for (const mode of ['edit', 'stage', 'follow']) { test(`opportunity ${mode} retains all required-field guards with field-specific feedback`, async () => { const original = opportunityHarness(mode) for (const field of original.fields.value.filter((f: model.Field) => f.required)) { const h = opportunityHarness(mode, { [field.key]: ' ' }) await h.save() assert.equal(h.payloads.length, 0) assert.ok(h.error.value.includes(field.label), h.error.value) assert.equal(h.notices.at(-1), h.error.value) } }) } test('opportunity name minimum length remains enforced', async () => { const h = opportunityHarness('edit', { opportunityName: '单' }) await h.save() assert.equal(h.payloads.length, 0) assert.match(h.error.value, /至少两个/) }) for (const edit of [false, true]) { test(`opportunity follow ${edit ? 'edit' : 'create'} retains its payload and optional next date`, async () => { const h = opportunityHarness('follow', edit ? { id: 'follow-1' } : {}) await h.save() assert.deepEqual(h.routes, [edit ? 'follow-edit' : 'follow-create']) assert.equal(h.payloads[0].opportunityId, 'opportunity-1') assert.equal(h.payloads[0].followTime, '2026-10-22 09:30:00') assert.equal(h.payloads[0].nextTime, '') assert.equal(h.payloads[0].nextContent, '下次计划') assert.equal(h.payloads[0].attachments, 'fixture.pdf') }) } const lifecycleFactories = { contact: (write: () => Promise) => { const h = contactHarness(false, {}, write) return { ...h, pending: h.acting, error: h.saveError, opened: () => h.editing.value } }, customer: (write: () => Promise) => { const h = customerHarness(false, {}, { write }) return { ...h, pending: h.saving, opened: () => h.navigations.length === 0 } }, opportunity: (write: () => Promise) => { const h = opportunityHarness('edit', {}, write) return { ...h, pending: h.busy, opened: () => h.mode.value === 'edit' } } } for (const [name, factory] of Object.entries(lifecycleFactories)) { test(`${name} API failure retains draft and supports retry`, async () => { let fail = true const h = factory(async () => { if (fail) throw new Error('保存失败测试') }) const before = { ...h.form.value } await h.save() assert.equal(h.error.value, '保存失败测试') assert.equal(h.notices.at(-1), h.error.value) assert.equal(h.opened(), true) assert.equal(h.pending.value, false) assert.deepEqual(h.form.value, before) fail = false await h.save() assert.equal(h.payloads.length, 2) assert.equal(h.error.value, '') }) test(`${name} pending save blocks a second submission`, async () => { let resolve!: () => void const h = factory( () => new Promise((done) => { resolve = done }) ) const pending = h.save() await h.save() while (!resolve) await Promise.resolve() assert.equal(h.pending.value, true) assert.equal(h.payloads.length, 1) resolve() await pending assert.equal(h.pending.value, false) }) } test('number field bounds reach Vant and all affected SFCs compile', () => { const source = readFileSync(new URL('CustomerFields.vue', base), 'utf8') assert.match(source, /:min="field.minValue"/) assert.match(source, /:max="field.maxValue"/) for (const file of [ 'CustomerFields.vue', 'ContactPanel.vue', 'form.vue', 'FollowPanel.vue', 'OpportunityActions.vue' ]) { const { descriptor, errors } = parse(readFileSync(new URL(file, base), 'utf8')) assert.deepEqual(errors, []) 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, []) } })