import assert from 'node:assert/strict' import test from 'node:test' import { readFileSync, existsSync } from 'node:fs' import { parse } from '@vue/compiler-sfc' import ts from 'typescript' import * as vue from 'vue' import { useClientColumnFilters } from '../src/composables/useClientColumnFilters' import * as customerFilterDefinitions from '../src/views/crm/customer/dialogColumnFilterDefs' import { contactFields } from '../src/views/m/crm/customer/model' import { contactDetailsError, requiredFieldsError } from '../src/views/m/crm/customer/validation' function deferred() { let resolve!: (value?: any) => void let reject!: (error: any) => void const promise = new Promise((a, b) => { resolve = a reject = b }) return { promise, resolve, reject } } const flush = async () => { for (let i = 0; i < 20; i++) await Promise.resolve() } // Execute actual script-setup functions inside Vue's renderer; API responses alone are deferred. const helperPath = new URL('../src/utils/mp-customer-panel-identity.ts', import.meta.url) function evaluate(page: string, props: any, overrides: Record, events: any[]) { const source = parse( readFileSync(new URL(`../src/views/m/crm/customer/${page}.vue`, import.meta.url), 'utf8') ).descriptor.scriptSetup!.content const ast = ts.createSourceFile('panel.ts', source, ts.ScriptTarget.Latest, true) const vueEntries = Object.entries(vue).filter( ([k]) => /^[a-zA-Z_$][\w$]*$/.test(k) && k !== 'default' ) const bindings: Record = Object.fromEntries(vueEntries) delete bindings.default for (const statement of ast.statements) { if (!ts.isImportDeclaration(statement)) continue const clause = statement.importClause if (clause?.name) bindings[clause.name.text] = () => undefined if (clause?.namedBindings && ts.isNamedImports(clause.namedBindings)) for (const item of clause.namedBindings.elements) bindings[item.name.text] = () => undefined } if (existsSync(helperPath)) { const helper = ts.transpile( readFileSync(helperPath, 'utf8').replace('export function', 'function'), { target: ts.ScriptTarget.ESNext } ) bindings.useCustomerPanelIdentity = new Function( ...vueEntries.map(([k]) => k), `${helper};return useCustomerPanelIdentity` )(...vueEntries.map(([, v]) => v)) } Object.assign(bindings, { useClientColumnFilters, ...customerFilterDefinitions, defineProps: () => props, defineEmits: () => (...args: any[]) => events.push(args), defineExpose() {}, useRouter: () => ({ push: async () => {} }), useDictStore: () => ({ getOptions: () => [], getLabel: () => '', getTagType: () => '' }), unwrapRows: (v: any) => (Array.isArray(v) ? v : v?.data?.rows || v?.data || v?.rows || []), unwrapData: (v: any) => v?.data || v, canDeleteByCreator: () => true, contactFields: [{ key: 'contactName', required: true }], invoiceFields: [{ key: 'companyName' }], requiredFieldsError: () => '', contactDetailsError: () => '', percentageError: () => '', apiTime: (v: any) => v, opportunityProgress: () => ({}), showCrmConfirmDialog: async () => {}, fetchContactByCustomerId: async () => [], fetchFollowByCustomerId: async () => [], getInvoiceInfoByCustomerId: async () => [], fetchContactDetail: async (v: any) => v, fetchUserOptions: async () => [], fetchCustomerOptions: async () => [], listOpportunity: async () => [], listContract: async () => [], listOperLog: async () => [], getOpportunity: async (id: string) => ({ id }), listFollowByOpportunity: async () => [], getStageLogList: async () => [], ...overrides }) const statements = ast.statements.filter((s) => !ts.isImportDeclaration(s)) const names = statements.flatMap((s) => ts.isFunctionDeclaration(s) ? [s.name!.text] : ts.isVariableStatement(s) ? s.declarationList.declarations.map((d) => d.name.getText(ast)) : [] ) const code = ts.transpile(statements.map((s) => s.getText(ast)).join('\n'), { target: ts.ScriptTarget.ESNext }) return new Function(...Object.keys(bindings), `${code};return {${names.join(',')}}`)( ...Object.values(bindings) ) } function mount(page: string, overrides: Record = {}) { const routeState = vue.reactive({ params: { id: 'A' }, query: { tab: 'basic' }, path: '/A' }) const route = { get id() { return routeState.params.id }, set id(id: string) { routeState.params.id = id }, get tab() { return routeState.query.tab }, set tab(tab: string) { routeState.query.tab = tab } } const events: any[] = [] let api: any let actions: any const renderer = vue.createRenderer({ patchProp() {}, insert() {}, remove() {}, createElement: () => ({}), createText: () => ({}), createComment: () => ({}), setText() {}, setElementText() {}, parentNode: () => null, nextSibling: () => null }) const NestedActions = vue.defineComponent({ props: ['opportunity', 'getIdentity'], setup(props) { actions = evaluate('OpportunityActions', props, overrides, events) return () => vue.h('div') } }) const Child = vue.defineComponent({ props: ['customerId', 'opportunity', 'getIdentity', 'kind'], setup(props) { api = evaluate( page === 'RelatedPanelActions' ? 'RelatedPanel' : page, props, overrides, events ) return () => page === 'RelatedPanelActions' && api.detail.value && api.detailOpen.value ? vue.h(NestedActions, { opportunity: api.detail.value, getIdentity: api.identity.key }) : vue.h('div') } }) const Parent = vue.defineComponent({ setup() { const parent = evaluate( 'detail', {}, { useRoute: () => routeState, useRouter: () => ({ replace() {}, push() {} }) }, [] ) const getIdentity = parent.getPanelIdentity || (() => route.id) return () => vue.h(Child, { customerId: route.id, opportunity: { id: route.id, status: 'following' }, kind: 'opportunity', getIdentity }) } }) const app = renderer.createApp(Parent) app.mount({}) return { get api() { return api }, get actions() { return actions }, route, events, unmount: () => app.unmount() } } const listApis: Record = { ContactPanel: 'fetchContactByCustomerId', FollowPanel: 'fetchFollowByCustomerId', InvoicePanel: 'getInvoiceInfoByCustomerId', RelatedPanel: 'listOpportunity' } test('ContactPanel: editing visible fields preserves hidden contact values in the update payload', async () => { const writes: any[] = [] const original = { id: 'contact-A', contactName: '验收联系人', mobile: '19900000000', qq: 'synthetic-qq', birthday: '2000-02-29', address: '验收合成住址', department: '原部门' } const h = mount('ContactPanel', { contactFields, contactDetailsError, requiredFieldsError, fetchContactDetail: async () => ({ data: original }), fetchUpdateContact: async (payload: any) => writes.push(payload) }) try { await h.api.open({ id: original.id }) assert.equal(h.api.visibleContactFields.value.some((field: any) => field.hidden), false) h.api.form.value.department = '已改部门' await h.api.save() assert.equal(writes.length, 1) assert.equal(writes[0].department, '已改部门') for (const key of ['qq', 'birthday', 'address']) assert.equal(writes[0][key], original[key]) assert.equal(writes[0].id, original.id) assert.equal(writes[0].customerId, 'A') assert.equal(original.department, '原部门') assert.equal(h.api.editing.value, false) } finally { h.unmount() } }) test('ContactPanel: opening another contact does not carry hidden fields from the previous draft', async () => { const writes: any[] = [] const h = mount('ContactPanel', { contactFields, contactDetailsError, requiredFieldsError, fetchContactDetail: async ({ id }: { id: string }) => ({ data: { id, contactName: id, mobile: '19900000000', ...(id === 'contact-A' ? { qq: 'synthetic-qq', birthday: '2000-02-29', address: '验收合成住址' } : { qq: null }) } }), fetchUpdateContact: async (payload: any) => writes.push(payload) }) try { await h.api.open({ id: 'contact-A' }) h.api.closeEditing() await h.api.open({ id: 'contact-B' }) h.api.form.value.department = '第二位联系人部门' await h.api.save() assert.equal(writes.length, 1) assert.equal(writes[0].id, 'contact-B') for (const key of ['qq', 'birthday', 'address']) assert.equal(writes[0][key], '') } finally { h.unmount() } }) test('ContactPanel: failed update retains hidden values and retries the same edited payload', async () => { const writes: any[] = [] const h = mount('ContactPanel', { contactFields, contactDetailsError, requiredFieldsError, fetchContactDetail: async () => ({ data: { id: 'contact-A', contactName: '验收联系人', mobile: '19900000000', qq: 'synthetic-qq', birthday: '2000-02-29', address: '验收合成住址' } }), fetchUpdateContact: async (payload: any) => { writes.push(payload) if (writes.length === 1) throw new Error('验收模拟保存失败') } }) try { await h.api.open({ id: 'contact-A' }) h.api.form.value.department = '重试仍保留的新部门' await h.api.save() assert.equal(h.api.editing.value, true) assert.equal(h.api.acting.value, false) assert.equal(h.api.saveError.value, '验收模拟保存失败') assert.equal(h.api.form.value.birthday, '2000-02-29') assert.equal(h.api.form.value.department, '重试仍保留的新部门') await h.api.save() assert.equal(writes.length, 2) assert.deepEqual(writes[1], writes[0]) assert.equal(writes[1].qq, 'synthetic-qq') assert.equal(writes[1].address, '验收合成住址') assert.equal(h.api.editing.value, false) assert.equal(h.api.saveError.value, '') } finally { h.unmount() } }) test('InvoicePanel: setting default announces success only after the write succeeds', async () => { const write = deferred() const messages: string[] = [] const h = mount('InvoicePanel', { request: { post: () => write.promise }, showSuccessToast: (message: string) => messages.push(message) }) try { await flush() const pending = h.api.setDefault({ id: 'invoice-A' }) assert.deepEqual(messages, []) write.resolve() await pending assert.deepEqual(messages, ['设置成功']) assert.equal(h.api.acting.value, false) } finally { h.unmount() } }) test('InvoicePanel: rejected default write retains its error without announcing success', async () => { const messages: string[] = [] const h = mount('InvoicePanel', { request: { post: async () => { throw new Error('验收请求失败') } }, showSuccessToast: (message: string) => messages.push(message) }) try { await flush() await h.api.setDefault({ id: 'invoice-A' }) assert.deepEqual(messages, []) assert.equal(h.api.error.value, '验收请求失败') assert.equal(h.api.acting.value, false) } finally { h.unmount() } }) for (const outcome of ['switch', 'ABA', 'unmount', 'same-customer-tab']) { test(`InvoicePanel: default success feedback respects ${outcome}`, async () => { const write = deferred() const messages: string[] = [] const h = mount('InvoicePanel', { request: { post: () => write.promise }, showSuccessToast: (message: string) => messages.push(message) }) await flush() const pending = h.api.setDefault({ id: 'invoice-A' }) if (outcome === 'unmount') h.unmount() else if (outcome === 'same-customer-tab') h.route.tab = 'invoice' else { h.route.id = 'B' if (outcome === 'ABA') h.route.id = 'A' } write.resolve() try { await pending assert.deepEqual(messages, outcome === 'same-customer-tab' ? ['设置成功'] : []) } finally { if (outcome !== 'unmount') h.unmount() } }) } for (const [page, read] of Object.entries(listApis)) { test(`${page}: late initial load cannot overwrite B or ABA`, async () => { const old = deferred() let calls = 0 const h = mount(page, { [read]: () => (++calls === 1 ? old.promise : Promise.resolve([{ id: 'fresh' }])) }) h.route.id = 'B' await flush() h.route.id = 'A' await flush() old.resolve([{ id: 'old' }]) await flush() assert.deepEqual(h.api.rows.value, [{ id: 'fresh' }]) h.unmount() }) } const deletes: Record = { ContactPanel: 'fetchDeleteContact', FollowPanel: 'fetchDeleteCustomerFollow', InvoicePanel: 'delInvoiceInfo', OpportunityActions: 'delOpportunity' } for (const [page, write] of Object.entries(deletes)) { for (const outcome of ['switch', 'ABA', 'unmount']) test(`${page}: confirmation resolves before same-tick ${outcome}`, async () => { const confirm = deferred(), writes: any[] = [] const h = mount(page, { showCrmConfirmDialog: () => confirm.promise, [write]: async (v: any) => writes.push(v) }) await flush() const pending = h.api.remove(page === 'OpportunityActions' ? undefined : { id: 'row-A' }) confirm.resolve() if (outcome === 'unmount') h.unmount() else { h.route.id = 'B' if (outcome === 'ABA') h.route.id = 'A' } await pending assert.deepEqual(writes, []) assert.deepEqual(h.events, []) if (outcome !== 'unmount') h.unmount() }) test(`${page}: same-ID tab query retains legitimate confirmation`, async () => { const confirm = deferred(), writes: any[] = [] const h = mount(page, { showCrmConfirmDialog: () => confirm.promise, [write]: async (v: any) => writes.push(v) }) await flush() const pending = h.api.remove(page === 'OpportunityActions' ? undefined : { id: 'row-A' }) h.route.tab = 'contact' confirm.resolve() await pending assert.equal(writes.length, 1) h.unmount() }) } test('ContactPanel: reversed detail opens retain latest row', async () => { const first = deferred(), second = deferred() const h = mount('ContactPanel', { fetchContactDetail: ({ id }: any) => (id === 'one' ? first.promise : second.promise) }) const a = h.api.open({ id: 'one' }), b = h.api.open({ id: 'two' }) second.resolve({ id: 'two' }) await b first.resolve({ id: 'one' }) await a assert.equal(h.api.form.value.id, 'two') h.unmount() }) test('OpportunityActions: reversed customer searches retain latest query', async () => { const first = deferred(), second = deferred() const h = mount('OpportunityActions', { fetchCustomerOptions: ({ keyword }: any) => (keyword === 'one' ? first.promise : second.promise) }) await h.api.open('edit') h.api.customerKeyword.value = 'one' const a = h.api.searchCustomers() h.api.customerKeyword.value = 'two' const b = h.api.searchCustomers() second.resolve([{ id: 'two', customerName: 'two' }]) await b first.resolve([{ id: 'one', customerName: 'one' }]) await a assert.equal(h.api.customers.value[0].value, 'two') h.unmount() }) const saves: Record = { ContactPanel: 'fetchCreateContact', FollowPanel: 'fetchCreateCustomerFollow', InvoicePanel: 'addInvoiceInfo', OpportunityActions: 'addOpportunityFollow' } for (const [page, write] of Object.entries(saves)) { test(`${page}: old save completion does not close, emit or clear new busy`, async () => { const old = deferred(), fresh = deferred() let count = 0 const h = mount(page, { [write]: () => (++count === 1 ? old.promise : fresh.promise) }) await flush() await (page === 'OpportunityActions' ? h.api.open('follow') : h.api.open()) Object.assign(h.api.form.value, { contactName: 'A', companyName: 'A', followContent: 'A', followType: 'phone' }) const a = h.api.save() h.route.id = 'B' await flush() await (page === 'OpportunityActions' ? h.api.open('follow') : h.api.open()) Object.assign(h.api.form.value, { contactName: 'B', companyName: 'B', followContent: 'B', followType: 'phone' }) const b = h.api.save() old.resolve() await a assert.equal((h.api.acting || h.api.busy).value, true) assert.equal( (h.api.editing || h.api.mode).value, page === 'OpportunityActions' ? 'follow' : true ) assert.deepEqual(h.events, []) fresh.resolve() await b assert.equal((h.api.acting || h.api.busy).value, false) h.unmount() }) } test('FollowPanel: close/reopen ignores old contacts error', async () => { const first = deferred(), second = deferred() let calls = 0 const h = mount('FollowPanel', { fetchContactByCustomerId: () => (++calls === 1 ? first.promise : second.promise) }) const a = h.api.open() h.api.closeEditing() const b = h.api.open() second.resolve([{ id: 'new' }]) await b first.reject(new Error('old error')) await a assert.equal(h.api.saveError.value, '') assert.deepEqual(h.api.contacts.value, [{ id: 'new' }]) h.unmount() }) test('RelatedPanel: reversed opportunity opens keep fresh detail and actions', async () => { const first = deferred(), second = deferred() const h = mount('RelatedPanel', { getOpportunity: (id: string) => (id === 'one' ? first.promise : second.promise) }) const a = h.api.open({ id: 'one' }), b = h.api.open({ id: 'two' }) second.resolve({ id: 'two' }) await b first.resolve({ id: 'one' }) await a assert.equal(h.api.detail.value.id, 'two') h.unmount() }) for (const transition of ['openB', 'close', 'closeReopenA', 'routeABA']) { test(`nested RelatedPanel/OpportunityActions: confirmation cannot survive ${transition}`, async () => { const confirm = deferred(), writes: any[] = [] const h = mount('RelatedPanelActions', { showCrmConfirmDialog: () => confirm.promise, delOpportunity: async (id: string) => writes.push(id) }) await flush() await h.api.open({ id: 'opportunity-A' }) await flush() const pending = h.actions.remove() confirm.resolve() if (transition === 'openB') void h.api.open({ id: 'opportunity-B' }) if (transition === 'close') h.api.closeDetail() if (transition === 'closeReopenA') { h.api.closeDetail() void h.api.open({ id: 'opportunity-A' }) } if (transition === 'routeABA') { h.route.id = 'B' h.route.id = 'A' } await pending assert.deepEqual(writes, []) assert.deepEqual(h.events, []) h.unmount() }) } for (const [page, read] of Object.entries(listApis)) { test(`${page}: old list error and finally cannot overwrite B loading`, async () => { const first = deferred(), second = deferred() let calls = 0 const h = mount(page, { [read]: () => (++calls === 1 ? first.promise : second.promise) }) h.route.id = 'B' await flush() first.reject(new Error('old error')) await flush() assert.equal(h.api.error.value, '') assert.equal(h.api.loading.value, true) second.resolve([{ id: 'B' }]) await flush() assert.deepEqual(h.api.rows.value, [{ id: 'B' }]) h.unmount() }) } test('ContactPanel: primary confirmation identifies the selected contact before writing', async () => { const confirmation = deferred() const prompts: any[] = [] const writes: any[] = [] const h = mount('ContactPanel', { showCrmConfirmDialog: (prompt: any) => { prompts.push(prompt) return confirmation.promise }, fetchSetPrimaryContact: async (value: any) => writes.push(value) }) try { await flush() const pending = h.api.primary({ id: 'contact-A', contactName: '验收联系人甲' }) assert.equal(prompts[0].message, '确认将「验收联系人甲」设置为主要联系人吗?') assert.deepEqual(writes, []) confirmation.reject('cancel') await pending assert.deepEqual(writes, []) assert.equal(h.api.error.value, '') } finally { confirmation.reject('cancel') h.unmount() } }) for (const outcome of ['success', 'failure', 'switch', 'ABA', 'unmount']) { test(`ContactPanel: primary feedback respects ${outcome}`, async () => { const write = deferred() const messages: string[] = [] const h = mount('ContactPanel', { fetchSetPrimaryContact: () => write.promise, showSuccessToast: (message: string) => messages.push(message) }) await flush() const pending = h.api.primary({ id: 'contact-A', contactName: '验收联系人甲' }) await flush() assert.deepEqual(messages, []) if (outcome === 'unmount') h.unmount() if (outcome === 'switch' || outcome === 'ABA') { h.route.id = 'B' if (outcome === 'ABA') h.route.id = 'A' } if (outcome === 'failure') write.reject(new Error('验收请求失败')) else write.resolve() try { await pending assert.deepEqual(messages, outcome === 'success' ? ['设置成功'] : []) if (outcome === 'failure') assert.equal(h.api.error.value, '验收请求失败') } finally { if (outcome !== 'unmount') h.unmount() } }) } test('ContactPanel: stale primary confirmation cannot set a row on the next customer', async () => { const confirm = deferred(), writes: any[] = [] const h = mount('ContactPanel', { showCrmConfirmDialog: () => confirm.promise, fetchSetPrimaryContact: async (v: any) => writes.push(v) }) await flush() const pending = h.api.primary({ id: 'contact-A' }) confirm.resolve() h.route.id = 'B' await pending assert.deepEqual(writes, []) h.unmount() }) test('InvoicePanel: default write failure cannot replace the next customer error or busy', async () => { const first = deferred(), second = deferred() let calls = 0 const h = mount('InvoicePanel', { request: { post: () => (++calls === 1 ? first.promise : second.promise) } }) await flush() const a = h.api.setDefault({ id: 'invoice-A' }) h.route.id = 'B' await flush() const b = h.api.setDefault({ id: 'invoice-B' }) first.reject(new Error('old error')) await a assert.equal(h.api.error.value, '') assert.equal(h.api.acting.value, true) second.resolve() await b assert.equal(h.api.acting.value, false) h.unmount() }) test('OpportunityActions: old owner options cannot replace a reopened edit session', async () => { const first = deferred(), second = deferred() let calls = 0 const h = mount('OpportunityActions', { fetchUserOptions: () => (++calls === 1 ? first.promise : second.promise) }) const a = h.api.open('edit') const b = h.api.open('edit') second.resolve([{ value: 'B', label: 'B' }]) await b first.resolve([{ value: 'A', label: 'A' }]) await a assert.deepEqual(h.api.users.value, [{ value: 'B', label: 'B' }]) h.unmount() }) test('OpportunityActions: delete uses the captured follow ID', async () => { const confirm = deferred(), writes: any[] = [] const h = mount('OpportunityActions', { showCrmConfirmDialog: () => confirm.promise, delOpportunityFollow: async (v: any) => writes.push(v) }) const row = { id: 'follow-A' } const pending = h.api.remove(row) row.id = 'follow-B' confirm.resolve() await pending assert.deepEqual(writes, ['follow-A']) assert.deepEqual(h.events, [['changed']]) h.unmount() })