| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447 |
- 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<string, any>, 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<any>({})
- 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<any>({ 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, /<button[\s\S]*?class="contract-picker__chip"[\s\S]*?:aria-label=/)
- assert.match(text, /\.contract-picker__chip\s*\{[^}]*min-height:\s*44px/s)
- })
- test('mobile primary buttons define their local background tokens instead of inheriting Vant blue', () => {
- 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<any>(undefined)
- const browser = { innerHeight: 700, scrollY: 0 }
- const bounds: Record<string, number> = { 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')
- })
|