Files
shanxia2.0/web-admin/scripts/mobile-customer-follow-save.test.ts
2026-10-22 16:24:46 +08:00

248 lines
7.9 KiB
TypeScript

import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { ref } from 'vue'
import { parse } from '@vue/compiler-sfc'
import ts from 'typescript'
import { apiTime, unwrapRows } from '../src/views/m/crm/customer/model'
const file = new URL('../src/views/m/crm/customer/FollowPanel.vue', import.meta.url)
const { descriptor } = parse(readFileSync(file, 'utf8'))
const ast = ts.createSourceFile(
'FollowPanel.ts',
descriptor.scriptSetup!.content,
ts.ScriptTarget.Latest,
true
)
const states = [
'rows',
'contacts',
'loading',
'acting',
'editing',
'error',
'saveError',
'form',
'upload'
]
const methods = ['open', 'save', 'load']
const source = ast.statements
.filter((node) => {
if (ts.isVariableStatement(node)) {
return states.includes(node.declarationList.declarations[0].name.getText(ast))
}
return (
ts.isFunctionDeclaration(node) &&
[...methods, 'showSaveError'].includes(node.name?.text || '')
)
})
.map((node) => node.getText(ast))
.join('\n')
// Execute the real component state, open/save/load functions and date formatter.
// Only transport and the attachment component are stubbed; no API or browser is used.
function harness(create: () => Promise<unknown> = async () => undefined) {
const payloads: Record<string, any>[] = []
const events: string[] = []
const notices: string[] = []
let reloads = 0
const bindings = {
ref,
// This payload fixture stays on one customer; session transitions are covered by renderer tests.
identity: {
newSession() {},
begin: () => ({ id: 'customer-test', valid: () => true })
},
apiTime,
unwrapRows,
showFailToast: (message: string) => notices.push(message),
props: { customerId: 'customer-test' },
emit: (event: string) => events.push(event),
fetchContactByCustomerId: async () => [],
fetchFollowByCustomerId: async () => {
reloads++
return [{ id: 'saved-follow' }]
},
fetchCreateCustomerFollow: async (payload: Record<string, any>) => {
payloads.push(payload)
return create()
}
}
const code = ts.transpile(source, { target: ts.ScriptTarget.ESNext })
const instance = new Function(
...Object.keys(bindings),
`${code}\nreturn { ${[...states, ...methods].join(', ')} }`
)(...Object.values(bindings))
return { ...instance, payloads, events, notices, reloads: () => reloads }
}
async function draft(h: ReturnType<typeof harness>) {
await h.open()
Object.assign(h.form.value, {
followContent: '联系内容',
attachment: 'fixture.pdf',
remark: '保留备注'
})
}
test('an omitted optional next contact date is absent from the serialized save payload', async () => {
const h = harness()
await draft(h)
assert.equal(h.form.value.nextTime, '')
await h.save()
assert.equal(h.payloads.length, 1)
assert.equal(h.payloads[0].nextTime, undefined)
assert.equal(Object.hasOwn(JSON.parse(JSON.stringify(h.payloads[0])), 'nextTime'), false)
assert.equal(h.payloads[0].customerId, 'customer-test')
assert.equal(h.payloads[0].attachment, 'fixture.pdf')
assert.equal(h.editing.value, false)
assert.equal(h.acting.value, false)
assert.deepEqual(h.events, ['changed'])
assert.equal(h.reloads(), 1)
assert.equal(h.rows.value[0].id, 'saved-follow')
})
test('null and missing optional next contact dates never serialize as empty strings', async () => {
for (const value of [null, undefined]) {
const h = harness()
await draft(h)
h.form.value.nextTime = value
await h.save()
assert.equal(h.payloads[0].nextTime, undefined)
assert.equal(Object.hasOwn(JSON.parse(JSON.stringify(h.payloads[0])), 'nextTime'), false)
}
})
test('a valid local date retains the selected time and gains API seconds only when needed', async () => {
for (const [input, expected] of [
['2026-10-22T09:30', '2026-10-22 09:30:00'],
['2026-10-22T09:30:45', '2026-10-22 09:30:45']
]) {
const h = harness()
await draft(h)
h.form.value.nextTime = input
await h.save()
assert.equal(h.payloads[0].nextTime, expected)
assert.equal(h.form.value.nextTime, input)
}
})
test('clearing a previously selected date submits no stale date or empty datetime', async () => {
const h = harness()
await draft(h)
h.form.value.nextTime = '2026-10-22T09:30'
h.form.value.nextTime = ''
await h.save()
assert.equal(h.payloads[0].nextTime, undefined)
assert.equal(h.form.value.nextTime, '')
})
test('an uploading attachment blocks submission and leaves the draft open', async () => {
const h = harness()
await draft(h)
h.upload.value = { hasUploading: () => true }
const before = { ...h.form.value }
await h.save()
assert.equal(h.payloads.length, 0)
assert.equal(h.saveError.value, '附件上传中,请稍候')
assert.equal(h.editing.value, true)
assert.equal(h.acting.value, false)
assert.deepEqual(h.form.value, before)
assert.equal(h.reloads(), 0)
assert.deepEqual(h.events, [])
})
test('a failed save retains all inputs and allows a corrected retry', async () => {
let failure = true
const h = harness(async () => {
if (failure) throw new Error('fixture save failure')
})
await draft(h)
h.form.value.nextTime = '2026-10-22T09:30'
const before = { ...h.form.value }
await h.save()
assert.equal(h.saveError.value, 'fixture save failure')
assert.equal(h.editing.value, true)
assert.equal(h.acting.value, false)
assert.deepEqual(h.form.value, before)
assert.deepEqual(h.events, [])
assert.equal(h.reloads(), 0)
failure = false
h.form.value.nextTime = ''
await h.save()
assert.equal(h.payloads.length, 2)
assert.equal(h.payloads[1].nextTime, undefined)
assert.equal(h.saveError.value, '')
assert.equal(h.editing.value, false)
})
test('a pending save blocks duplicate submission until it settles', async () => {
let resolve!: () => void
const h = harness(
() =>
new Promise<void>((done) => {
resolve = done
})
)
await draft(h)
const pending = h.save()
assert.equal(h.acting.value, true)
await h.save()
assert.equal(h.payloads.length, 1)
resolve()
await pending
assert.equal(h.acting.value, false)
})
test('required content validation still runs before transport', async () => {
const h = harness()
await h.open()
h.form.value.followContent = ' '
await h.save()
assert.equal(h.payloads.length, 0)
assert.equal(h.saveError.value, '跟进内容不能为空')
assert.equal(h.editing.value, true)
})
test('follow save pairs the selected contact ID with its loaded canonical name', async () => {
for (const selectedId of [42, '42']) {
const h = harness()
await draft(h)
h.contacts.value = [{ id: 42, contactName: '当前联系人' }]
h.form.value.contactId = selectedId
h.form.value.contactName = '过期姓名'
await h.save()
assert.equal(h.payloads[0].contactName, '当前联系人')
assert.equal(String(h.payloads[0].contactId), '42')
}
})
test('cleared and unknown follow contacts never submit a stale name', async () => {
for (const selectedId of ['', null, undefined, 'unknown']) {
const h = harness()
await draft(h)
h.contacts.value = [{ id: 'known', contactName: '旧联系人' }]
h.form.value.contactId = selectedId
h.form.value.contactName = '过期姓名'
await h.save()
assert.equal(h.payloads[0].contactName, '')
}
})
test('a cleared follow type blocks transport with visible field feedback and retains the draft', async () => {
for (const value of ['', ' ', null, undefined]) {
const h = harness()
await draft(h)
h.form.value.followType = value
const before = { ...h.form.value }
await h.save()
assert.equal(h.payloads.length, 0)
assert.equal(h.saveError.value, '跟进方式不能为空')
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)
}
})