248 lines
7.9 KiB
TypeScript
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)
|
|
}
|
|
})
|