233 lines
8.4 KiB
TypeScript
233 lines
8.4 KiB
TypeScript
import assert from 'node:assert/strict'
|
|
import { readFileSync } from 'node:fs'
|
|
import test from 'node:test'
|
|
import { computed, ref } from 'vue'
|
|
import { parse } from '@vue/compiler-sfc'
|
|
import ts from 'typescript'
|
|
import { getMpQuoteAmountSummary } from '../src/utils/mp-crm-quote-card'
|
|
|
|
function component(path: string) {
|
|
return parse(readFileSync(new URL(path, import.meta.url), 'utf8')).descriptor
|
|
}
|
|
|
|
const formDescriptor = component('../src/views/crm/quote/components/QuoteFormPanel.vue')
|
|
const detailDescriptor = component('../src/views/m/crm/quote/detail.vue')
|
|
|
|
// Execute actual production declarations. Only transport/UI side effects are stubbed.
|
|
function declarations(source: string, names: string[], bindings: Record<string, any>) {
|
|
const ast = ts.createSourceFile('component.ts', source, ts.ScriptTarget.Latest, true)
|
|
const selected = ast.statements.filter((node) =>
|
|
ts.isVariableStatement(node)
|
|
? names.includes(node.declarationList.declarations[0].name.getText(ast))
|
|
: ts.isFunctionDeclaration(node) && names.includes(node.name?.text || '')
|
|
)
|
|
const code = ts
|
|
.transpile(
|
|
selected
|
|
.map((node) => node.getText(ast))
|
|
.join('\n')
|
|
.replace(/export /g, ''),
|
|
{
|
|
target: ts.ScriptTarget.ESNext,
|
|
module: ts.ModuleKind.None
|
|
}
|
|
)
|
|
.replace(/export /g, '')
|
|
const found = names.filter((name) =>
|
|
selected.some((node) =>
|
|
ts.isVariableStatement(node)
|
|
? node.declarationList.declarations[0].name.getText(ast) === name
|
|
: (node as ts.FunctionDeclaration).name?.text === name
|
|
)
|
|
)
|
|
return new Function(...Object.keys(bindings), `${code}\nreturn { ${found.join(', ')} }`)(
|
|
...Object.values(bindings)
|
|
)
|
|
}
|
|
|
|
function formHarness(page: boolean, prepare: () => boolean | void = () => {}) {
|
|
const calls: Array<{ kind: string; payload: any }> = []
|
|
let validation: Promise<void> | undefined
|
|
const form = ref({
|
|
id: 'quote-1',
|
|
customerId: 'customer-new',
|
|
contactId: 'contact-new',
|
|
contactName: '新姓名',
|
|
finalAmount: 25,
|
|
freight: 5,
|
|
barrelDeposit: 3,
|
|
products: [
|
|
{
|
|
productId: 'product-1',
|
|
productName: '产品',
|
|
quantity: 2,
|
|
price: 12.5,
|
|
subtotal: 25,
|
|
packageSpec: '桶',
|
|
deliveryMethod: '送货'
|
|
}
|
|
]
|
|
})
|
|
const transport = (kind: string) => async (payload: any) => {
|
|
calls.push({ kind, payload: JSON.parse(JSON.stringify(payload)) })
|
|
return {}
|
|
}
|
|
const bindings = {
|
|
ref,
|
|
computed,
|
|
form,
|
|
isPageVariant: ref(page),
|
|
formRef: ref({
|
|
validate: (fn: (valid: boolean) => Promise<void>) => {
|
|
validation = fn(true)
|
|
}
|
|
}),
|
|
submitFormRef: ref({ validate: async () => true }),
|
|
submitForm: ref({ approverIds: ['approver-1'], remark: '' }),
|
|
submitDialog: ref({ loading: false, visible: true }),
|
|
saveLoading: ref(false),
|
|
attachmentList: ref([]),
|
|
pageMissingMessage: () => '',
|
|
syncQuoteProductsForSubmit: prepare,
|
|
showFailToast: () => {},
|
|
ElMessage: { warning: () => {} },
|
|
$modal: { msgSuccess: () => {}, msgError: () => {}, msgWarning: () => {} },
|
|
updateQuote: transport('web-update'),
|
|
updateMobileQuote: transport('mobile-update'),
|
|
submitQuote: transport('submit'),
|
|
addQuote: transport('add'),
|
|
finishSuccess: () => {},
|
|
updateQuoteTitleAndNo: () => {}
|
|
}
|
|
const api = declarations(
|
|
formDescriptor.scriptSetup!.content,
|
|
['calculatedAmount', 'mobileFinalAmount', 'handleSaveDraft', 'handleConfirmApproval'],
|
|
bindings
|
|
)
|
|
return { ...bindings, ...api, calls, saved: () => validation }
|
|
}
|
|
|
|
for (const page of [true, false]) {
|
|
test(`${page ? 'mobile' : 'desktop'} draft save dispatches through its own endpoint`, async () => {
|
|
const h = formHarness(page)
|
|
h.handleSaveDraft()
|
|
await h.saved()
|
|
assert.deepEqual(
|
|
h.calls.map((call) => call.kind),
|
|
[page ? 'mobile-update' : 'web-update']
|
|
)
|
|
assert.equal(h.calls[0].payload.finalAmount, 25)
|
|
assert.equal(h.calls[0].payload.contactId, 'contact-new')
|
|
})
|
|
|
|
test(`${page ? 'mobile' : 'desktop'} approval saves via its own endpoint before submitting`, async () => {
|
|
const h = formHarness(page)
|
|
await h.handleConfirmApproval()
|
|
assert.deepEqual(
|
|
h.calls.map((call) => call.kind),
|
|
[page ? 'mobile-update' : 'web-update', 'submit']
|
|
)
|
|
assert.equal(h.calls[0].payload.finalAmount, 25)
|
|
assert.equal(h.calls[1].payload.id, 'quote-1')
|
|
})
|
|
}
|
|
|
|
test('mobile amount includes freight and deposit while desktop and payload keep product amount', () => {
|
|
const h = formHarness(true)
|
|
// Evaluate the actual mobile display expression, so the baseline displays 25 and fails.
|
|
const mobileExpression = formDescriptor.template!.content.match(
|
|
/<strong class="money-display">¥\{\{ (.*?) \}\}/
|
|
)![1]
|
|
const display = new Function(
|
|
'calculatedAmount',
|
|
'mobileFinalAmount',
|
|
`return ${mobileExpression}`
|
|
)(h.calculatedAmount.value, h.mobileFinalAmount?.value)
|
|
assert.equal(display, '33.00')
|
|
assert.equal(h.calculatedAmount.value.finalAmount, '25.00')
|
|
assert.equal(h.form.value.finalAmount, 25)
|
|
assert.match(
|
|
formDescriptor.template!.content,
|
|
/<span class="money-display" style="font-size: 20px">¥\{\{ calculatedAmount.finalAmount \}\}/
|
|
)
|
|
h.form.value.freight = 0
|
|
h.form.value.barrelDeposit = 0
|
|
assert.equal(h.mobileFinalAmount.value, '25.00')
|
|
})
|
|
|
|
test('mobile form display agrees with the detail amount and avoids adding fees to stored finalAmount', () => {
|
|
const h = formHarness(true)
|
|
const detail = declarations(detailDescriptor.scriptSetup!.content, ['amountSummary'], {
|
|
computed,
|
|
getMpQuoteAmountSummary,
|
|
quote: ref({ totalAmount: 25, finalAmount: 25, freight: 5, barrelDeposit: 3 })
|
|
})
|
|
assert.equal(Number(h.mobileFinalAmount?.value), detail.amountSummary.value.finalAmount)
|
|
})
|
|
|
|
test('mobile API normalizes contact IDs without changing the original desktop serializer', async () => {
|
|
const source = readFileSync(new URL('../src/api/crm/quote/index.ts', import.meta.url), 'utf8')
|
|
const calls: any[] = []
|
|
const api = declarations(source, ['stringifyBody', 'updateQuote', 'updateMobileQuote'], {
|
|
BASE: '/web/crm/quote',
|
|
request: { post: async (payload: any) => calls.push(payload) }
|
|
})
|
|
assert.equal(typeof api.updateMobileQuote, 'function', 'mobile API wrapper is missing')
|
|
for (const id of [42, '9007199254740993', '', null, undefined]) {
|
|
const input = { id: 1, customerId: 2, contactId: id }
|
|
await api.updateMobileQuote(input)
|
|
assert.equal(calls.at(-1).url, '/web/crm/quote/mobile-update')
|
|
assert.equal(calls.at(-1).data.contactId, id == null ? id : String(id))
|
|
assert.equal(input.contactId, id)
|
|
}
|
|
await api.updateQuote({ id: 1, customerId: 2, contactId: 42 })
|
|
assert.equal(calls.at(-1).url, '/web/crm/quote/update')
|
|
assert.equal(calls.at(-1).data.contactId, 42)
|
|
})
|
|
|
|
test('rejection confirmation describes editing back to draft and retains approval behavior', async () => {
|
|
const dialogs: any[] = []
|
|
const requests: any[] = []
|
|
const bindings = {
|
|
// This confirmation test operates on a valid current approval session.
|
|
captureOperation: () => ({ id: 'quote-1', epoch: 1 }),
|
|
isCurrentOperation: () => true,
|
|
disposed: false,
|
|
operationEpoch: 1,
|
|
pendingApproval: ref({ id: 'approval-1' }),
|
|
approveResult: ref('rejected'),
|
|
approveOpinion: ref(' 请补充资料 '),
|
|
acting: ref(false),
|
|
approveOpen: ref(true),
|
|
showFailToast: () => {},
|
|
showSuccessToast: () => {},
|
|
loadDetail: async () => {},
|
|
showCrmConfirmDialog: async (dialog: any) => {
|
|
dialogs.push(dialog)
|
|
},
|
|
approveQuote: async (payload: any) => {
|
|
requests.push(payload)
|
|
}
|
|
}
|
|
const h = declarations(detailDescriptor.scriptSetup!.content, ['confirmApprove'], bindings)
|
|
await h.confirmApprove()
|
|
assert.doesNotMatch(dialogs[0].message, /无法再次提交审批/)
|
|
assert.match(dialogs[0].message, /编辑.*草稿.*重新提交审批/)
|
|
assert.deepEqual(requests, [
|
|
{ id: 'approval-1', approvalResult: 'rejected', approvalOpinion: '请补充资料' }
|
|
])
|
|
assert.equal(bindings.acting.value, false)
|
|
assert.equal(bindings.approveOpen.value, false)
|
|
})
|
|
|
|
for (const action of ['draft', 'approval']) {
|
|
test(`mobile ${action} refuses writes when metadata preparation is blocked`, async () => {
|
|
const h = formHarness(true, () => false)
|
|
if (action === 'draft') {
|
|
h.handleSaveDraft()
|
|
await h.saved()
|
|
} else await h.handleConfirmApproval()
|
|
assert.equal(h.calls.length, 0)
|
|
})
|
|
}
|