Files
shanxia2.0/web-admin/scripts/mobile-crm-record-identity.test.ts
T

528 lines
18 KiB
TypeScript

import assert from 'node:assert/strict'
import test from 'node:test'
import { readFileSync } 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()
}
function harness(page: string, overrides: Record<string, any> = {}) {
const content = parse(
readFileSync(new URL(`../src/views/m/crm/${page}.vue`, import.meta.url), 'utf8')
).descriptor.scriptSetup!.content
const ast = ts.createSourceFile('page.ts', content, ts.ScriptTarget.Latest, true)
const route = vue.reactive({
name:
page === 'customer/detail'
? 'MpCrmCustomerDetail'
: page === 'quote/detail'
? 'MpCrmQuoteDetail'
: page === 'contract/detail'
? 'MpCrmContractDetail'
: undefined,
params: { id: 'A' },
query: {} as Record<string, string>,
fullPath: '/A',
get path() {
return page === 'quote/detail' || page === 'contract/detail'
? `/m/crm/${page.split('/')[0]}/${this.params.id}`
: '/A'
}
})
const unmounts: Array<() => void> = []
const writes: any[] = [],
pushes: any[] = [],
calls: string[] = []
const bindings: Record<string, any> = Object.fromEntries(
Object.entries(vue).filter(([key]) => /^[a-zA-Z_$][\w$]*$/.test(key) && key !== 'default')
)
for (const s of ast.statements) {
if (!ts.isImportDeclaration(s)) continue
const clause = s.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
}
const read = async (arg: any) => {
const id = typeof arg === 'string' ? arg : arg.id
calls.push(id)
return { data: { id, status: 'draft', products: [], productList: [] } }
}
Object.assign(bindings, {
useRoute: () => route,
useRouter: () => ({
push: (v: any) => pushes.push(v),
replace: async (v: any) => {
if (typeof v === 'object') route.query = v.query
else pushes.push(v)
}
}),
useUserStore: () => ({ info: { id: 'user' } }),
useDictStore: () => ({ getLabel: () => '', getTagType: () => '' }),
onMounted() {},
onUnmounted: (fn: any) => unmounts.push(fn),
onBeforeUnmount: (fn: any) => unmounts.push(fn),
getContract: read,
getQuote: read,
fetchCustomerDetail: read,
fetchProductDetail: read,
listContractApproval: async () => ({ data: [] }),
listApproval: async () => ({ data: [] }),
listQuoteProduct: async () => ({ rows: [] }),
fetchCategoryTree: async () => ({ data: [] }),
buildPathLabelMapFromTreeRoots: () => new Map(),
unwrapData: (v: any) => v.data ?? v,
customerForm: (v: any) => v,
canDeleteByCreator: () => true,
isContractEditableStatus: () => true,
ElMessage: { warning() {}, error() {}, success() {} },
showCrmConfirmDialog: async () => {},
showProductConfirmDialog: async () => {},
fetchDeleteCustomer: async (v: any) => writes.push(v),
fetchDeleteProduct: async (v: any) => writes.push(v),
delContract: async (v: any) => writes.push(v),
delQuote: async (v: any) => writes.push(v),
fetchUpdateProductStatus: async (v: any) => writes.push(v),
defineExpose() {},
defineProps: () => vue.reactive({ modelValue: true, ids: ['A'], embedded: false }),
withDefaults: (props: any) => props,
defineEmits:
() =>
(...v: any[]) =>
pushes.push(v),
fetchIntoPool: async (v: any) => writes.push(v),
document: { querySelector: () => null },
window: { removeEventListener() {}, scrollTo() {} },
...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
})
const scope = vue.effectScope()
const api: any = scope.run(() =>
new Function(...Object.keys(bindings), `${code};return {${names.join(',')}}`)(
...Object.values(bindings)
)
)
return {
...api,
route,
calls,
writes,
pushes,
ready: api.onShellReady || api.onReady,
record: api.contract || api.quote || api.customer || api.detail,
unmount: () => {
unmounts.forEach((fn) => fn())
scope.stop()
}
}
}
for (const page of ['contract/detail', 'quote/detail', 'customer/detail', 'product/detail']) {
test(`${page}: ready gate, immediate identity reset and one read per ID`, async () => {
const h = harness(page)
h.route.params.id = 'B'
await flush()
assert.deepEqual(h.calls, [])
h.ready()
await flush()
assert.deepEqual(h.calls, ['B'])
h.ready()
await flush()
assert.deepEqual(h.calls, ['B'])
h.route.params.id = 'C'
assert.ok(!h.record.value)
await flush()
assert.deepEqual(h.calls, ['B', 'C'])
h.route.query.tab = 'products'
h.route.fullPath = '/C?tab=products'
await flush()
assert.deepEqual(h.calls, ['B', 'C'])
h.unmount()
})
test(`${page}: mismatched successful response is never actionable`, async () => {
const bad = async () => ({ data: { id: 'WRONG' } })
const h = harness(page, {
getContract: bad,
getQuote: bad,
fetchCustomerDetail: bad,
fetchProductDetail: bad
})
h.ready()
await flush()
assert.ok(!h.record.value)
h.unmount()
})
test(`${page}: confirmation from A cannot delete after switching`, async () => {
const confirmation = deferred()
const h = harness(page, {
showCrmConfirmDialog: () => confirmation.promise,
showProductConfirmDialog: () => confirmation.promise
})
h.ready()
await flush()
const pending = (h.remove || h.onDelete)()
h.route.params.id = 'B'
await flush()
confirmation.resolve()
await pending
assert.deepEqual(h.writes, [])
h.unmount()
})
}
test('pool: changed targets during confirmation cannot submit', async () => {
const confirmation = deferred(),
props = vue.reactive({ modelValue: true, ids: ['A'] })
const h = harness('customer/CustomerPoolSheet', {
defineProps: () => props,
showCrmConfirmDialog: () => confirmation.promise
})
h.reason.value = 'reason'
const pending = h.submit()
props.ids = ['B']
confirmation.resolve()
await pending
assert.deepEqual(h.writes, [])
h.unmount()
})
for (const page of ['contract/detail', 'quote/detail', 'customer/detail', 'product/detail']) {
for (const outcome of ['success', 'failure', 'ABA', 'unmount']) {
test(`${page}: delayed ${outcome} cannot publish into a newer session`, async () => {
const old = deferred()
let first = true
const read = async (arg: any) => {
const id = typeof arg === 'string' ? arg : arg.id
if (first) {
first = false
return old.promise
}
return { data: { id, marker: 'fresh', status: 'draft' } }
}
const h = harness(page, {
getContract: read,
getQuote: read,
fetchCustomerDetail: read,
fetchProductDetail: read
})
h.ready()
await flush()
if (outcome === 'unmount') h.unmount()
else {
h.route.params.id = 'B'
await flush()
assert.equal(h.record.value.id, 'B')
if (outcome === 'ABA') {
h.route.params.id = 'A'
await flush()
}
}
if (outcome === 'failure') old.reject(Error('old failure'))
else old.resolve({ data: { id: 'A', marker: 'old' } })
await flush()
if (outcome === 'unmount') assert.ok(!h.record.value)
else {
assert.equal(h.record.value.marker, 'fresh')
assert.equal((h.errorText || h.error).value, '')
assert.equal(h.loading.value, false)
h.unmount()
}
})
}
test(`${page}: old sent delete cannot navigate or clear new operation busy`, async () => {
const sent = deferred(),
fresh = deferred()
let count = 0
const write = () => (++count === 1 ? sent.promise : fresh.promise)
const h = harness(page, {
delContract: write,
delQuote: write,
fetchDeleteCustomer: write,
fetchDeleteProduct: write
})
h.ready()
await flush()
const old = (h.remove || h.onDelete)()
await flush()
assert.equal(count, 1)
h.route.params.id = 'B'
await flush()
const current = (h.remove || h.onDelete)()
await flush()
assert.equal(count, 2)
sent.resolve()
await old
assert.deepEqual(h.pushes, [])
assert.equal((h.acting || h.deleting).value, true)
fresh.resolve()
await current
assert.equal(h.pushes.length, 1)
h.unmount()
})
}
for (const page of ['contract/detail', 'quote/detail']) {
test(`${page}: mode change invalidates confirmation and query-only changes preserve submit`, async () => {
const confirmation = deferred()
const h = harness(page, {
showCrmConfirmDialog: () => confirmation.promise,
fetchUserOptions: async () => []
})
h.ready()
await flush()
await h.openSubmitSheet()
h.route.query.tab = 'products'
h.route.fullPath = '/A?tab=products'
await flush()
assert.equal(h.submitOpen.value, true)
const pending = h.onDelete()
h.route.query.from = 'approve'
assert.equal(h.submitOpen.value, false)
confirmation.resolve()
await pending
assert.deepEqual(h.writes, [])
await h.onDelete()
assert.deepEqual(h.writes, [])
assert.equal(h.calls.length, 1)
h.unmount()
})
test(`${page}: route action consumes own query once and preserves new sheet`, async () => {
const h = harness(page, { fetchUserOptions: async () => [] })
h.route.query.action = 'submit'
h.ready()
await flush()
assert.equal(h.route.query.action, undefined)
assert.equal(h.submitOpen.value, true)
assert.deepEqual(h.calls, ['A'])
await h.runRouteAction()
assert.equal(h.submitOpen.value, true)
h.unmount()
})
test(`${page}: route action awaiting replace cannot open on another record or mode`, async () => {
const replacing = deferred()
const h = harness(page, { useRouter: () => ({ replace: () => replacing.promise, push() {} }) })
h.ready()
await flush()
h.route.query.action = 'submit'
const pending = h.runRouteAction()
h.route.query.from = 'approve'
replacing.resolve()
await pending
assert.equal(h.submitOpen.value, false)
h.unmount()
})
}
for (const stage of ['products', 'enrichment', 'approval']) {
test(`quote: delayed ${stage} publishes only local current-record values`, async () => {
const delayed = deferred()
const h = harness('quote/detail', {
getQuote: async (id: string) => ({
data: { id, productList: stage === 'products' ? [] : [{ productId: id, name: id }] }
}),
listQuoteProduct: (id: string) =>
id === 'A' ? delayed.promise : Promise.resolve({ rows: [{ productId: id, name: id }] }),
enrichQuoteDetailProductMeta: async (lines: any[]) => {
if (stage === 'enrichment' && lines[0]?.productId === 'A') {
await delayed.promise
lines[0].name = 'stale'
}
},
listApproval: (id: string) =>
stage === 'approval' && id === 'A' ? delayed.promise : Promise.resolve({ data: [{ id }] })
})
h.ready()
await flush()
h.route.params.id = 'B'
await flush()
assert.equal(h.products.value[0].name, 'B')
delayed.resolve({ rows: [{ name: 'stale' }], data: [{ id: 'stale' }] })
await flush()
assert.equal(h.record.value.id, 'B')
assert.equal(h.products.value[0].name, 'B')
assert.equal(h.approvalList.value[0].id, 'B')
h.unmount()
})
}
for (const timing of ['picker', 'first upload', 'sent attachment write']) {
test(`contract: ${timing} session is invalidated by switching ID`, async () => {
const delayed = deferred(),
uploads: any[] = [],
attachments: any[] = []
const h = harness('contract/detail', {
parseCrmAttachmentField: () => [],
buildAttachmentUploadFormData: (file: any) => file,
CRM_UPLOAD_BIZ_TYPE: { CONTRACT_ATTACHMENT: 'contract' },
fetchUploadAttachment: (file: any) => {
uploads.push(file)
return timing === 'first upload'
? delayed.promise
: Promise.resolve({ data: { fileUrl: file } })
},
uploadAttachments: (body: any) => {
attachments.push(body)
return delayed.promise
}
})
h.ready()
await flush()
let clicked = 0
h.fileInputRef.value = { click: () => clicked++ }
await h.runAction('upload')
assert.equal(clicked, 1)
if (timing === 'picker') {
h.route.params.id = 'B'
await flush()
}
const pending = h.onFilesChosen({ target: { files: ['one', 'two'], value: 'selected' } })
await flush()
if (timing !== 'picker') {
h.route.params.id = 'B'
await flush()
}
h.acting.value = true
delayed.resolve({ data: { fileUrl: 'one' } })
await pending
assert.equal(uploads.length, timing === 'picker' ? 0 : timing === 'first upload' ? 1 : 2)
assert.equal(attachments.length, timing === 'sent attachment write' ? 1 : 0)
assert.equal(h.record.value.id, 'B')
assert.equal(h.acting.value, true)
assert.deepEqual(h.calls, ['A', 'B'])
h.unmount()
})
}
for (const invalidation of ['close', 'prop close', 'unmount', 'ABA']) {
test(`pool: ${invalidation} invalidates waiting confirmation`, async () => {
const confirmation = deferred(),
props = vue.reactive({ modelValue: true, ids: ['A'] })
const h = harness('customer/CustomerPoolSheet', {
defineProps: () => props,
showCrmConfirmDialog: () => confirmation.promise
})
h.reason.value = 'reason'
const pending = h.submit()
if (invalidation === 'close') h.close()
if (invalidation === 'prop close') props.modelValue = false
if (invalidation === 'unmount') h.unmount()
if (invalidation === 'ABA') {
props.ids = ['B']
props.ids = ['A']
}
confirmation.resolve()
await pending
assert.deepEqual(h.writes, [])
if (invalidation !== 'unmount') h.unmount()
})
}
test('pool: captured reason and ids are stable; sent stale result cannot emit success', async () => {
const confirm = deferred(),
sent = deferred(),
writes: any[] = []
const props = vue.reactive({ modelValue: true, ids: ['A'] })
const h = harness('customer/CustomerPoolSheet', {
defineProps: () => props,
showCrmConfirmDialog: () => confirm.promise,
fetchIntoPool: (body: any) => {
writes.push(body)
return sent.promise
}
})
h.reason.value = 'original'
const pending = h.submit()
h.reason.value = 'changed'
confirm.resolve()
await flush()
assert.deepEqual(writes, [{ ids: ['A'], reason: 'original' }])
props.ids = ['B']
h.saving.value = true
sent.resolve()
await pending
assert.equal(h.saving.value, true)
assert.deepEqual(h.pushes, [])
h.unmount()
})
for (const page of ['contract/detail', 'quote/detail', 'customer/detail', 'product/detail']) {
test(`${page}: confirmation rechecks loading before write`, async () => {
const confirmation = deferred()
const h = harness(page, {
showCrmConfirmDialog: () => confirmation.promise,
showProductConfirmDialog: () => confirmation.promise
})
h.ready()
await flush()
const pending = (h.remove || h.onDelete)()
h.loading.value = true
confirmation.resolve()
await pending
assert.deepEqual(h.writes, [])
h.unmount()
})
}
test('quote: approval writes the pending approval ID, never the quote ID', async () => {
const writes: any[] = []
const h = harness('quote/detail', {
getQuote: async (id: string) => ({ data: { id, status: 'submitted' } }),
listApproval: async () => ({ data: [{ id: 'approval-record', approverId: 'user' }] }),
findPendingQuoteApproval: (rows: any[]) => rows[0],
approveQuote: async (body: any) => writes.push(body)
})
h.route.query.from = 'approve'
h.ready()
await flush()
await h.runAction('approve')
await h.confirmApprove()
assert.equal(writes.length, 1)
assert.equal(writes[0].id, 'approval-record')
assert.equal(h.acting.value, false)
h.unmount()
})
test('customer: direct edit requires loaded current identity', async () => {
const h = harness('customer/detail')
h.ready()
await flush()
h.customer.value = { id: 'WRONG' }
h.goEdit()
assert.deepEqual(h.pushes, [])
h.customer.value = { id: 'A' }
h.loading.value = true
h.goEdit()
assert.deepEqual(h.pushes, [])
h.unmount()
})
test('pool: equivalent ids replacement preserves reason and pending confirmation', async () => {
const confirmation = deferred()
const props = vue.reactive({ modelValue: true, ids: ['A'] })
const h = harness('customer/CustomerPoolSheet', {
defineProps: () => props,
showCrmConfirmDialog: () => confirmation.promise
})
h.reason.value = '保留原因'
const pending = h.submit()
assert.equal(h.saving.value, true)
props.ids = ['A']
assert.equal(h.reason.value, '保留原因')
assert.equal(h.saving.value, true)
confirmation.resolve()
await pending
assert.deepEqual(h.writes, [{ ids: ['A'], reason: '保留原因' }])
assert.equal(h.pushes.filter((event: any[]) => event[0] === 'success').length, 1)
assert.equal(h.saving.value, false)
h.unmount()
})