Files
shanxia2.0/web-admin/scripts/mobile-customer-detail-lifecycle.test.ts

335 lines
11 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'
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() {},
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 }],
requiredFieldsError: () => '',
contactDetailsError: () => '',
showCrmConfirmDialog: async () => {},
fetchContactByCustomerId: async () => [],
fetchContactDetail: async (v: any) => v,
...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)
)
}
const detailRoute = (id = 'A', tab = 'basic') => ({
name: 'MpCrmCustomerDetail',
path: `/m/crm/customer/${id}`,
params: { id },
query: { tab }
})
function mount(initial = detailRoute(), withChild = false) {
const current = vue.shallowRef(initial)
const route = Object.fromEntries(['name', 'path', 'params', 'query'].map((key) => [key, null]))
for (const key of Object.keys(route))
Object.defineProperty(route, key, { get: () => current.value[key] })
const reads: any[] = [],
writes: any[] = [],
messages: any[] = [],
navigation: any[] = []
const confirm = deferred()
let api: any
let child: any
const childRead = deferred()
const childWrites: any[] = []
const renderer = vue.createRenderer<any, any>({
patchProp() {},
insert() {},
remove() {},
createElement: () => ({}),
createText: () => ({}),
createComment: () => ({}),
setText() {},
setElementText() {},
parentNode: () => null,
nextSibling: () => null
})
const Child = vue.defineComponent({
props: ['customerId', 'getIdentity'],
setup(props) {
child = evaluate(
'ContactPanel',
props,
{
fetchContactByCustomerId: () => childRead.promise,
showCrmConfirmDialog: () => confirm.promise,
fetchDeleteContact: async (value: any) => childWrites.push(value)
},
[]
)
return () => vue.h('div')
}
})
const app = renderer.createApp({
setup() {
api = evaluate(
'detail',
{},
{
useRoute: () => route,
useRouter: () => ({
replace: (v: any) => navigation.push(v),
push: (v: any) => navigation.push(v)
}),
fetchCustomerDetail: (params: any) => {
const d = deferred()
reads.push({ ...d, params })
return d.promise
},
fetchDeleteCustomer: (params: any) => {
const d = deferred()
writes.push({ ...d, params })
return d.promise
},
showCrmConfirmDialog: () => confirm.promise,
customerForm: (v: any) => v,
showFailToast: (v: any) => messages.push(v),
showSuccessToast: (v: any) => messages.push(v)
},
[]
)
return () =>
withChild && api.customer.value && !api.loading.value
? vue.h(Child, { customerId: api.id.value, getIdentity: api.getPanelIdentity })
: vue.h('div')
}
})
app.mount({})
return {
api,
reads,
writes,
messages,
navigation,
confirm,
childRead,
childWrites,
get child() {
return child
},
go: (value: any) => {
current.value = value
},
unmount: () => app.unmount()
}
}
const destinations = [
{ name: 'MpCrmCustomerList', path: '/m/crm/customer', params: {}, query: {} },
{ name: 'MpCrmProductDetail', path: '/m/crm/product/B', params: { id: 'B' }, query: {} },
{ name: 'MpCrmProductDetail', path: '/m/crm/product/A', params: { id: 'A' }, query: {} },
{ name: 'MpCrmCustomerEdit', path: '/m/crm/customer/A/edit', params: { id: 'A' }, query: {} },
detailRoute('')
]
for (const destination of destinations) {
test(`leave to ${destination.name}/${destination.params.id || 'empty'} blocks all reads and invalidates synchronously`, async () => {
const h = mount()
h.api.onReady()
const key = h.api.getPanelIdentity()
h.go(destination)
assert.notEqual(h.api.getPanelIdentity(), key)
assert.equal(h.reads.length, 1, 'leaving must not request a customer')
await h.api.load()
await h.api.load('A')
await h.api.refreshInfo()
h.api.onReady()
assert.equal(h.reads.length, 1)
h.reads[0].resolve({ id: 'A', customerName: 'stale' })
await flush()
assert.equal(h.api.customer.value, undefined)
assert.equal(h.api.error.value, '')
assert.deepEqual(h.messages, [])
h.unmount()
})
}
test('ready gate, same-ID tab/query changes, retries and same-path reloads', async () => {
const h = mount()
await h.api.load()
await h.api.refreshInfo()
h.go(detailRoute('B'))
assert.equal(h.reads.length, 0)
h.api.onReady()
h.api.onReady()
assert.equal(h.reads.length, 1)
assert.equal(h.reads[0].params.id, 'B')
h.go(detailRoute('B', 'contact'))
assert.equal(h.api.tab.value, 'contact')
assert.equal(h.reads.length, 1)
h.reads[0].reject(new Error('retryable'))
await flush()
assert.equal(h.api.error.value, 'retryable')
const retry = h.api.load()
h.reads[1].resolve({ id: 'B' })
await retry
const reload = h.api.load()
h.reads[2].resolve({ id: 'B' })
await reload
const refresh = h.api.refreshInfo()
h.reads[3].resolve({ id: 'B', customerName: 'fresh' })
await refresh
assert.equal(h.api.customer.value.customerName, 'fresh')
h.unmount()
})
for (const aba of [false, true])
for (const rejectOld of [false, true]) {
test(`latest route wins: ABA=${aba} reject=${rejectOld}`, async () => {
const h = mount()
h.api.onReady()
h.go(detailRoute('B'))
if (aba) h.go(detailRoute('A'))
const latest = h.reads.at(-1)
for (const read of h.reads.slice(0, -1)) {
if (rejectOld) read.reject(new Error('old failure'))
else read.resolve({ id: read.params.id, customerName: 'stale' })
}
await flush()
assert.equal(h.api.loading.value, true)
assert.equal(h.api.error.value, '')
assert.equal(h.api.customer.value, undefined)
latest.resolve({ id: latest.params.id, customerName: 'latest' })
await flush()
assert.equal(h.api.customer.value.customerName, 'latest')
h.unmount()
})
}
test('customer detail has no deletion entry and leaving prevents stale edit navigation', async () => {
const h = mount()
h.api.onReady()
h.reads[0].resolve({ id: 'A' })
await flush()
assert.equal(h.api.remove, undefined)
h.go(destinations[2])
h.api.goEdit()
assert.deepEqual(h.writes, [])
assert.deepEqual(h.navigation, [])
h.unmount()
})
for (const destination of destinations)
test(`late ready outside valid detail: ${destination.name}/${destination.params.id || 'empty'}`, async () => {
const h = mount()
h.go(destination)
h.api.onReady()
assert.equal(h.reads.length, 0)
h.unmount()
})
test('refresh failure after same-ID leave is silent; reentry can load', async () => {
const h = mount()
h.api.onReady()
h.reads[0].resolve({ id: 'A' })
await flush()
const refresh = h.api.refreshInfo()
h.go(destinations[2])
h.reads[1].reject(new Error('old refresh'))
await refresh
assert.deepEqual(h.messages, [])
h.go(detailRoute())
assert.equal(h.reads.length, 3)
h.reads[2].resolve({ id: 'A', customerName: 'back' })
await flush()
assert.equal(h.api.customer.value.customerName, 'back')
h.unmount()
})
test('same-ID leave invalidates real child panel reads and confirmations before unmount', async () => {
const h = mount(detailRoute(), true)
h.api.onReady()
h.reads[0].resolve({ id: 'A' })
await flush()
const child = h.child
const pending = child.remove({ id: 'contact-A' })
h.confirm.resolve()
h.childRead.resolve([{ id: 'stale' }])
h.go(destinations[2])
assert.deepEqual(child.rows.value, [])
assert.equal(child.acting.value, false)
await pending
await flush()
assert.deepEqual(h.childWrites, [])
assert.deepEqual(child.rows.value, [])
h.unmount()
})
test('unmount makes pending reads and all entry points inert', async () => {
const h = mount()
h.api.onReady()
h.unmount()
h.reads[0].reject(new Error('late'))
await flush()
await h.api.load()
await h.api.refreshInfo()
h.api.onReady()
assert.equal(h.reads.length, 1)
assert.equal(h.api.error.value, '')
assert.deepEqual(h.messages, [])
})