import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' import { computed, ref, watch, nextTick, effectScope } from 'vue' import { parse } from '@vue/compiler-sfc' import ts from 'typescript' import * as brand from '../src/utils/crm-product-brand' import * as support from '../src/views/m/crm/contract/mobileFormState' const parentPath = new URL( '../src/views/crm/contract/components/ContractFormDialog.vue', import.meta.url ) const parent = parse(readFileSync(parentPath, 'utf8')).descriptor const ast = ts.createSourceFile( 'parent.ts', parent.scriptSetup!.content, ts.ScriptTarget.Latest, true ) function select(name: string) { const node = ast.statements.find( (node) => (ts.isFunctionDeclaration(node) && node.name?.text === name) || (ts.isVariableStatement(node) && node.declarationList.declarations.some((d) => d.name.getText(ast) === name)) ) assert.ok(node, `Production declaration ${name} exists`) return node.getText(ast) } function evaluate(names: string[], bindings: Record, extra = '') { const code = ts.transpile(names.map(select).join('\n') + '\n' + extra, { target: ts.ScriptTarget.ESNext }) return new Function(...Object.keys(bindings), code + ';return {' + names.join(',') + '}')( ...Object.values(bindings) ) } function metaHarness(fetchProductDetail: any) { const bindings: any = { ...brand, ...support, isPageVariant: ref(true), fetchProductDetail, console: { warn: () => {} }, mobileGeneration: 1, assertMobileCurrent: () => {}, mobileProductMetaError: ref(''), mobileProductMetaRetry: ref(null), mobileProductMetaCanDiscard: ref(false), form: ref({ products: [] }), calculateProductAmount: () => {}, calculateTotalAmount: () => {} } return { ...bindings, ...evaluate(['enrichContractProductBrandMeta'], bindings) } } test('mobile metadata failure rejects initialization and preserves the saved required-brand snapshot', async () => { const h = metaHarness(async () => { throw new Error('offline') }) const rows = [ { productId: 'p', productName: '甲醇', brandMode: 'required', brandId: 'brand1', brandName: '指定品牌', brandOptions: [] } ] await assert.rejects(h.enrichContractProductBrandMeta(rows), /offline|产品/) assert.deepEqual(rows[0], { productId: 'p', productName: '甲醇', brandMode: 'required', brandId: 'brand1', brandName: '指定品牌', brandOptions: [] }) assert.ok(h.mobileProductMetaError.value) assert.equal(typeof h.mobileProductMetaRetry.value, 'function') }) test('mobile metadata updates are atomic when another line fails', async () => { const rows = [ { productId: 'p1', productName: '原名称', brandMode: 'required', brandId: 'b', brandName: '原品牌', brandOptions: [] }, { productId: 'p2', productName: '二' } ] const before = structuredClone(rows) const h = metaHarness(async ({ id }: any) => { if (id === 'p2') throw new Error('offline') return { data: { id, productName: '新名称', aliases: [], brands: [] } } }) await assert.rejects(h.enrichContractProductBrandMeta(rows)) assert.deepEqual(rows, before) }) test('money fields reject negative, non-finite and overprecision values, accepting zero and optional blanks', () => { assert.equal(typeof support.validateMobileContractMoney, 'function') for (const key of ['amount', 'freight', 'barrelDeposit']) { assert.ok(support.validateMobileContractMoney({ [key]: -1 })) assert.ok(support.validateMobileContractMoney({ [key]: '1.234' })) assert.ok(support.validateMobileContractMoney({ [key]: 'NaN' })) assert.equal(support.validateMobileContractMoney({ [key]: '0.29' }), '') assert.equal(support.validateMobileContractMoney({ [key]: 0 }), '') assert.equal(support.validateMobileContractMoney({ [key]: '' }), '') } }) test('new mobile contracts default to the local calendar date while desktop retains its original date', async () => { const previous = process.env.TZ process.env.TZ = 'Asia/Shanghai' try { class MorningDate extends Date { constructor() { super('2026-10-20T23:00:00Z') } } for (const page of [true, false]) { const form = ref({}) const bindings = { ...support, isPageVariant: ref(page), Date: MorningDate, form, resetForm: () => {}, handleSignDateChange: () => {}, applyDefaultOwnerUser: () => {}, supplierList: ref([]) } const { initNewContractForm } = evaluate(['initNewContractForm'], bindings) await initNewContractForm() assert.equal(form.value.signDate, page ? '2026-10-21' : '2026-10-20') } } finally { if (previous === undefined) delete process.env.TZ else process.env.TZ = previous } }) test('mobile selected values revalidate registered Element fields after model updates', async () => { const fields: string[][] = [] const form = ref({ customerId: '', ownerUserId: '', settlementMethod: '' }) const expression = ast.statements.find( (node) => ts.isExpressionStatement(node) && node.getText(ast).startsWith('watch(') && node.getText(ast).includes('mobileValidationFields') ) assert.ok(expression, 'mobile selection watcher exists') const bindings = { ref, computed, watch, nextTick, isPageVariant: ref(true), mobileReady: ref(true), form, formRef: ref({ validateField: async (keys: string[]) => fields.push(keys) }) } const scope = effectScope() scope.run(() => evaluate( ['mobileValidationFields', 'syncMobileFieldValidation'], bindings, expression!.getText(ast) ) ) form.value.customerId = 'c' form.value.ownerUserId = 'u' form.value.settlementMethod = 'cash' await nextTick() await nextTick() assert.ok(fields.flat().includes('customerId')) assert.ok(fields.flat().includes('ownerUserId')) assert.ok(fields.flat().includes('settlementMethod')) scope.stop() }) test('nonempty contact picker exposes the quick-create action', () => { const text = readFileSync( new URL('../src/views/m/crm/contract/MobileContractForm.vue', import.meta.url), 'utf8' ) const tree = ts.createSourceFile( 'mobile.ts', parse(text).descriptor.scriptSetup!.content, ts.ScriptTarget.Latest, true ) const node = tree.statements.find( (n) => ts.isFunctionDeclaration(n) && n.name?.text === 'openContacts' )! assert.match(node.getText(tree), /actionText:\s*'新增联系人'/) const template = parse(text).descriptor.template!.content assert.match(template, /:action-text="picker.actionText"/) }) test('selected multi-pick removal is a labelled 44px button', () => { const text = readFileSync( new URL('../src/views/m/crm/contract/ContractPickerSheet.vue', import.meta.url), 'utf8' ) assert.match(text, / { for (const name of [ 'MobileContractForm', 'ContractPickerSheet', 'ContractSubmitSheet', 'ContractAuxiliarySheets' ]) { const text = readFileSync( new URL(`../src/views/m/crm/contract/${name}.vue`, import.meta.url), 'utf8' ) assert.match(text, /--van-button-primary-background:\s*#0a9f6d/) assert.match(text, /--van-button-primary-border-color:\s*#0a9f6d/) } }) test('metadata retry retains historical required brands through the real submit normalizer', async () => { let fail = true const h = metaHarness(async ({ id }: any) => { if (fail) throw new Error('offline') return { data: { id, productName: '甲醇', brands: [], aliases: [] } } }) const rows = [ { productId: 'p', productName: '甲醇', brandMode: 'required', brandId: 'brand1', brandName: '指定品牌', brandOptions: [] } ] await assert.rejects(h.enrichContractProductBrandMeta(rows)) fail = false await h.mobileProductMetaRetry.value() brand.prepareLineAliasAndBrandForSubmit(rows[0]) assert.equal(rows[0].brandMode, 'required') assert.equal(rows[0].brandId, 'brand1') assert.equal(rows[0].brandName, '指定品牌') assert.equal(h.mobileProductMetaError.value, '') assert.equal(h.mobileProductMetaRetry.value, null) }) test('new product selection adds nothing on metadata failure and a retry adds complete rows once', async () => { let fail = true const added: any[] = [] const h: any = { ...support, mobileGeneration: 1, form: ref({ supplierId: 's' }), mobileProductMetaError: ref(''), mobileProductMetaRetry: ref(null), mobileProductMetaCanDiscard: ref(false), assertMobileCurrent: () => {}, fetchProductDetail: async ({ id }: any) => { if (fail) throw new Error('offline') return { data: { id, productName: '甲醇', brands: [{ id: 'b', brandName: '品牌' }] } } }, handleProductSelected: (products: any[]) => added.push(...products) } const { addMobileProducts } = evaluate(['addMobileProducts'], h) await assert.rejects(addMobileProducts([{ id: 'p', productName: '列表名称' }])) assert.equal(added.length, 0) assert.ok(h.mobileProductMetaError.value) assert.equal(h.mobileProductMetaCanDiscard.value, true) fail = false await h.mobileProductMetaRetry.value() assert.equal(added.length, 1) assert.equal(added[0].brands[0].id, 'b') assert.equal(h.mobileProductMetaError.value, '') assert.equal(h.mobileProductMetaRetry.value, null) assert.equal(h.mobileProductMetaCanDiscard.value, false) }) test('superseded product metadata requests cannot mutate rows or overwrite current error state', async () => { let finish: any let current = true const row = { productId: 'p', productName: '旧名称', brandMode: 'required', brandId: 'b', brandName: '旧品牌', brandOptions: [] } const original = structuredClone(row) const h = metaHarness( () => new Promise((resolve) => { finish = resolve }) ) h.assertMobileCurrent = () => { if (!current) throw new Error('stale') } const { enrichContractProductBrandMeta } = evaluate(['enrichContractProductBrandMeta'], h) const request = enrichContractProductBrandMeta([row]) current = false h.mobileProductMetaError.value = 'current error' finish({ data: { id: 'p', productName: '新名称', brands: [], aliases: [] } }) await assert.rejects(request, /stale/) assert.deepEqual(row, original) assert.equal(h.mobileProductMetaError.value, 'current error') }) test('multiple selections of one product retain separate lines with one metadata lookup', async () => { let calls = 0 const details = await support.loadMobileContractProductDetails( [{ id: 'p' }, { id: 'p' }], async () => { calls++ return { data: { id: 'p', productName: '甲醇' } } } ) assert.equal(calls, 1) assert.equal(details.length, 2) }) test('metadata failure keeps actual mobile initialization not ready until retry succeeds', async () => { let fail = true const h: any = metaHarness(async ({ id }: any) => { if (fail) throw new Error('产品服务暂不可用') return { data: { id, productName: '甲醇', brands: [] } } }) const rows = [ { productId: 'p', productName: '甲醇', brandMode: 'required', brandId: 'brand1', brandName: '指定品牌', brandOptions: [] } ] Object.assign(h, { mobileReady: ref(false), mobileError: ref(''), mobileContactError: ref(''), formLoading: ref(false), emit: () => {}, resetForm: () => {}, props: { editId: 'contract', copySource: null }, loadSupplierList: async () => {}, loadUserList: async () => {}, loadContractDetail: async () => h.enrichContractProductBrandMeta(rows) }) const { initializeMobileForm } = evaluate(['initializeMobileForm'], h) await initializeMobileForm() assert.equal(h.mobileReady.value, false) assert.equal(h.formLoading.value, false) assert.match(h.mobileError.value, /产品服务/) assert.equal(rows[0].brandId, 'brand1') fail = false await initializeMobileForm() assert.equal(h.mobileReady.value, true) assert.equal(h.mobileError.value, '') assert.equal(h.mobileProductMetaError.value, '') }) test('desktop metadata keeps its prior tolerant failure path and never sets mobile errors', async () => { const h = metaHarness(async () => { throw new Error('offline') }) h.isPageVariant.value = false await h.enrichContractProductBrandMeta([{ productId: 'p' }]) assert.equal(h.mobileProductMetaError.value, '') assert.equal(h.mobileProductMetaRetry.value, null) }) test('mobile popup selection controls and navigation define local brand tokens', () => { for (const name of ['ContractSubmitSheet', 'ContractAuxiliarySheets']) { const source = readFileSync( new URL(`../src/views/m/crm/contract/${name}.vue`, import.meta.url), 'utf8' ) for (const token of ['radio-checked-icon-color', 'nav-bar-text-color', 'nav-bar-icon-color']) { assert.match(source, new RegExp(`--van-${token}:\\s*#0a9f6d`)) } if (name === 'ContractAuxiliarySheets') assert.match(source, /--van-switch-on-background:\s*#0a9f6d/) } }) test('form section tracking ignores loading/hidden and unscrolled short pages, but follows real scrolling', () => { const content = parse( readFileSync( new URL('../src/views/m/crm/contract/MobileContractForm.vue', import.meta.url), 'utf8' ) ).descriptor.scriptSetup!.content const tree = ts.createSourceFile('mobile-form.ts', content, ts.ScriptTarget.Latest, true) const node = tree.statements.find( (item) => ts.isFunctionDeclaration(item) && item.name?.text === 'syncSection' )! const c = { ready: false, loading: true } const activeSection = ref('base') const navRef = ref(undefined) const browser = { innerHeight: 700, scrollY: 0 } const bounds: Record = { base: 100, products: 500, attachments: 1000 } const doc = { documentElement: { scrollHeight: 600 }, querySelector: () => null, getElementById: (id: string) => ({ getBoundingClientRect: () => ({ top: bounds[id.replace('contract-form-', '')] }) }) } const sync = new Function( 'c', 'activeSection', 'navRef', 'headerHeight', 'sections', 'window', 'document', `let scrollLockUntil = 0; ${ts.transpile(node.getText(tree))}; return syncSection` )( c, activeSection, navRef, ref(66), ref([{ id: 'base' }, { id: 'products' }, { id: 'attachments' }]), browser, doc ) sync() assert.equal(activeSection.value, 'base', 'loading skeleton must not select attachments') c.ready = true c.loading = false navRef.value = { getBoundingClientRect: () => ({ bottom: 90 }), getClientRects: () => [] } sync() assert.equal(activeSection.value, 'base', 'hidden form must not select attachments') navRef.value.getClientRects = () => [{}] sync() assert.equal(activeSection.value, 'base', 'an unscrolled page cannot be treated as bottom') doc.documentElement.scrollHeight = 1800 browser.scrollY = 600 bounds.products = 90 sync() assert.equal(activeSection.value, 'products') browser.scrollY = 1100 sync() assert.equal(activeSection.value, 'attachments', 'real bottom still selects last section') })