| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646 |
- 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,
- /<form\b[^>]*\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<string, any>) {
- // 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<model.Row>({}),
- 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<void>) => {
- const h = contactHarness(false, {}, write)
- return { ...h, pending: h.acting, error: h.saveError, opened: () => h.editing.value }
- },
- customer: (write: () => Promise<void>) => {
- const h = customerHarness(false, {}, { write })
- return { ...h, pending: h.saving, opened: () => h.navigations.length === 0 }
- },
- opportunity: (write: () => Promise<void>) => {
- 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<void>((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, [])
- }
- })
|