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

474 lines
15 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'
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, {
defineProps: () => props,
defineEmits:
() =>
(...args: any[]) =>
events.push(args),
defineExpose() {},
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'
}
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: 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()
})