Files
shanxia2.0/web-admin/scripts/mobile-customer-panel-identity.test.ts

703 lines
23 KiB
TypeScript

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<any>((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<string, any>, 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<string, any> = 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<string, any> = {}) {
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<any, any>({
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<string, string> = {
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<string, string> = {
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<string, string> = {
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()
})