448 lines
15 KiB
TypeScript
448 lines
15 KiB
TypeScript
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')
|
|
})
|