Files
shanxia2.0/web-admin/scripts/mobile-quote-persistence.test.ts
T
2026-10-22 16:24:46 +08:00

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)
})
}