528 lines
18 KiB
TypeScript
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()
|
|
})
|