Files
shanxia2.0/web-admin/scripts/mobile-contract-review.test.ts

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