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) { 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 | 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) => { 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( /¥\{\{ (.*?) \}\}/ )![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, /¥\{\{ 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) }) }