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 = async () => undefined) { const payloads: Record[] = [] 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) => { 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) { 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((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) } })