Files
shanxia2.0/web-admin/scripts/mobile-pl-plan-stops.test.ts
T
2026-10-23 23:07:00 +08:00

175 lines
14 KiB
TypeScript

import assert from 'node:assert/strict'
import { readFileSync, existsSync } from 'node:fs'
import test from 'node:test'
import { parse, compileScript, compileTemplate } from '@vue/compiler-sfc'
import ts from 'typescript'
const root = new URL('../src/', import.meta.url)
const read = (path: string) => readFileSync(new URL(path, root), 'utf8')
const components = ['pl-plan-load-section','pl-plan-delivery-section','pl-plan-stop-product-table','pl-plan-contract-multi-select','pl-address-with-library']
for (const component of components) test(`${component}: mobile opt-in root and unchanged desktop template`, () => {
const path = `components/business/${component}/index.vue`
const d = parse(read(path)).descriptor
const roots = d.template!.ast.children.filter((n: any) => n.type === 1) as any[]
assert.match(roots[0].loc.source, /v-if="mobile/)
const baseline = parse(readFileSync(`/tmp/mobile-r3-before/${path}`, 'utf8')).descriptor
const old = (baseline.template!.ast.children.find((n: any) => n.type === 1) as any).loc.source
assert.equal(roots[1].loc.source.replace(/\s+v-else(?=\s|>)/, ''), old)
})
for (const component of ['MobilePlanLoadSection','MobilePlanDeliverySection','MobilePlanProductCards','MobilePlanContractCards','PlanFormPickerSheets']) test(`${component}: mobile template compiles without desktop controls`, () => {
const path = `views/m/pl/plan/${component}.vue`
assert.ok(existsSync(new URL(path, root)), 'independent mobile component exists')
const d = parse(read(path)).descriptor
assert.doesNotMatch(d.template!.content, /<el-(?:input|select|table|button|date-picker)\b/)
compileScript(d, { id: component })
assert.deepEqual(compileTemplate({source:d.template!.content, filename:path,id:component}).errors, [])
})
test('actual scroll controller accepts fractional layout position within one pixel', () => {
const source = parse(read('views/m/pl/plan/MobilePlPlanForm.vue')).descriptor.scriptSetup!.content
const ast = ts.createSourceFile('scroll.ts', source, ts.ScriptTarget.Latest, true)
const fn = ast.statements.find(n => ts.isFunctionDeclaration(n) && n.name?.text === 'syncScroll')!
const active = { value: 'load' }
const run = new Function('syncViewport','clickLock','navRef','sections','window','document','active',ts.transpile(fn.getText(ast))+';return syncScroll')
run(()=>{},0,{value:{getBoundingClientRect:()=>({bottom:111})}},[{id:'load'},{id:'delivery'}],{scrollY:2459,innerHeight:800},{documentElement:{scrollHeight:6000},getElementById:(id:string)=>({getBoundingClientRect:()=>({top:id.endsWith('delivery')?123.46875:-100})})},active)()
assert.equal(active.value,'delivery')
})
function productionFunction(path: string, name: string, bindings: Record<string, any>) {
const ast = ts.createSourceFile('controller.ts', parse(read(path)).descriptor.scriptSetup!.content, ts.ScriptTarget.Latest, true)
const fn = ast.statements.find(n => ts.isFunctionDeclaration(n) && n.name?.text === name)
assert.ok(fn, `production function ${name}`)
return new Function(...Object.keys(bindings), ts.transpile(fn.getText(ast)) + `;return ${name}`)(...Object.values(bindings))
}
test('mobile delivery preference response follows the original segment through reorder', async () => {
let resolve: (v: any) => void = () => {}
const a = { customerId: 'same', products: [], address: '' }, b = { customerId: 'same', products: [], address: 'B' }
const props = { mobileSheet: true, modelValue: [a, b] }
const calls: any[] = []
const fn = productionFunction('components/business/pl-plan-delivery-section/index.vue', 'applyDeliveryStopPref', {
props, customerStopPrefApi: { get: () => new Promise(r => { resolve = r }) }, pickBxData: (x: any) => x,
patchDelivery: (i: number, value: any) => calls.push([i,value]),
mobileStopIdentity: (x: any) => x.products
})
const pending = fn(0, 'same'); props.modelValue = [b, a]
resolve({ address: 'A' }); await pending
assert.deepEqual(calls, [[1,{address:'A'}]])
})
test('last mobile delivery cannot be removed by exposed controller', () => {
const row = { customerId:'A' }, calls: any[]=[]
const fn = productionFunction('components/business/pl-plan-delivery-section/index.vue','removeDelivery',{props:{mobileSheet:true,modelValue:[row]},emit:(...a:any[])=>calls.push(a),resequence:(a:any)=>a,createEmptyDeliveryForm:()=>({}),activeIndex:{value:0}})
fn(0); assert.deepEqual(calls,[])
})
test('quantity input retains decimal intermediate text while committing three decimals on blur', () => {
const quantityDrafts = new Map<any,string>(), row:any={planQty:0}
const path='views/m/pl/plan/MobilePlanProductCards.vue'
const set=productionFunction(path,'setQuantity',{quantityDrafts,c:{props:{editable:true}}})
for(const input of ['1','1.','1.1','1.12','1.125']) { set(row,input); assert.equal(quantityDrafts.get(row),input) }
assert.equal(row.planQty,1.125)
})
test('mobile metadata persistence blocks save until authoritative refresh and ignores removed rows', async () => {
const path = new URL('../src/views/m/pl/plan/mobilePlanMetadata.ts', import.meta.url)
assert.ok(existsSync(path),'mobile metadata guard exists')
const {createMobilePlanMetadata} = await import(path.href)
const a:any={productId:'p',aliasOptions:['old']}, b:any={productId:'p',aliasOptions:['old']}
let rows=[a,b]
const guard=createMobilePlanMetadata(()=>rows)
guard.persisted(a); assert.equal(guard.ready(),false)
guard.saved(a,{aliases:[{aliasName:'fresh'}],brands:[]})
assert.equal(guard.ready(),true); assert.deepEqual(b.aliasOptions,['fresh'])
rows=[]; guard.persisted(a); assert.equal(guard.ready(),true)
})
test('obsolete mobile contract detail response cannot replace a newer customer snapshot', async () => {
let resolve:(v:any)=>void=()=>{}
const props:any={mobile:true,customerId:'A',modelValue:[{contractId:'c1'}]}, detailRows:any={value:[]}, loadingDetails={value:false}
const fn=productionFunction('components/business/pl-plan-contract-multi-select/index.vue','loadDetails',{props,detailRows,loadingDetails,getContractDetailCached:()=>new Promise(r=>resolve=r),enrichContractProducts:async(x:any)=>x,mobileDetailVersion:{value:0}})
const pending=fn(['c1']); props.customerId='B'; detailRows.value=[{id:'c2'}]
resolve({id:'c1',products:[]});await pending
assert.deepEqual(detailRows.value,[{id:'c2'}])
})
import { buildLoadPayload, buildDeliveriesPayload, createEmptyLoadForm, createEmptyDeliveryForm, resolveStopDibangDefaults, canManualToggleStopDibang } from '../src/utils/pl-plan-stop-form'
import { collectStopContractIds, unionContractProductRows, allowedPackageSpecsForStopProduct, normalizePackageSpec } from '../src/utils/pl-plan-contract-product'
import { isPlInternalCustomerName } from '../src/utils/pl-internal-customer'
import { findFirstStopProductArrangeGap } from '../src/utils/pl-plan-stop-product-arrange-required'
import { mobileStopIdentity } from '../src/views/m/pl/plan/mobileStopIdentity'
test('payload keeps two same-product contract rows and original identities; mobile token never serializes', () => {
const load:any={...createEmptyLoadForm(),id:'stop',products:[{productId:'p',planProductId:'pp1',contractId:'a',contractProductId:'ca',packageSpec:'桶',planQty:1.125},{productId:'p',planProductId:'pp2',contractId:'b',contractProductId:'cb',packageSpec:'袋',planQty:2}]}
const identity=mobileStopIdentity(load)
assert.equal(mobileStopIdentity({...load}),identity)
const payload=buildLoadPayload(load)
assert.equal(payload.id,'stop');assert.equal(payload.products.length,2)
assert.deepEqual(payload.products.map(p=>[p.planProductId,p.contractId,p.contractProductId,p.packageSpec]),[['pp1','a','ca','桶'],['pp2','b','cb','袋']])
assert.equal(Object.getOwnPropertySymbols(payload).length,0)
const a:any={...createEmptyDeliveryForm(),id:'delivery',contact:'19900001111',notes:'保留',products:load.products}
mobileStopIdentity(a)
assert.equal(buildDeliveriesPayload([a])[0].contact,'19900001111')
assert.equal(Object.getOwnPropertySymbols(buildDeliveriesPayload([a])[0]).length,0)
})
test('actual formal contract validator rejects external no-contract and permits exact internal names', () => {
const loadForm={value:{customerName:'外部',contracts:[]}}, deliveryForms={value:[]}, messages:string[]=[]
const bindings={loadForm,deliveryForms,isPlInternalCustomerName,ElMessage:{warning:(s:string)=>messages.push(s)}}
const stopHasSelectedContract=productionFunction('components/business/pl-plan-form-dialog/index.vue','stopHasSelectedContract',{})
const valid=productionFunction('components/business/pl-plan-form-dialog/index.vue','validateStopContractsRequired',{...bindings,stopHasSelectedContract})
assert.equal(valid(),false)
loadForm.value.customerName='安庆市山峡化工有限公司';assert.equal(valid(),true)
loadForm.value.customerName='安徽云栋化工有限公司';assert.equal(valid(),true)
loadForm.value.customerName='安庆市山峡化工有限公司分公司';assert.equal(valid(),false)
})
test('actual formal packaging validator distinguishes same product contract line packaging', () => {
const product:any={productId:'p',contractId:'a',contractProductId:'ca',productName:'产品',packageSpec:'袋'}
const loadForm={value:{contracts:[{contractId:'a'},{contractId:'b'}],products:[product]}}
const contractProductRowsCache={value:{a:[{id:'ca',productId:'p',contractId:'a',packageSpec:'桶'}],b:[{id:'cb',productId:'p',contractId:'b',packageSpec:'袋'}]}}
const valid=productionFunction('components/business/pl-plan-form-dialog/index.vue','validateContractPackageSpecs',{loadForm,deliveryForms:{value:[]},contractProductRowsCache,collectStopContractIds,unionContractProductRows,allowedPackageSpecsForStopProduct,normalizePackageSpec,ElMessage:{error:()=>{}}})
assert.equal(valid(),false);product.packageSpec='桶';assert.equal(valid(),true)
})
test('zero quantity remains serializable for drafts but actual arrange validation rejects it', () => {
const row:any={productId:'p',unit:'吨',packageSpec:'桶',deliveryMethod:'送货',planQty:0}
assert.equal(buildLoadPayload({...createEmptyLoadForm(),products:[row]}).products[0].planQty,0)
assert.match(findFirstStopProductArrangeGap({loadProducts:[row]})!,/计划数量/)
row.planQty=1.125;assert.equal(findFirstStopProductArrangeGap({loadProducts:[row]}),null)
})
test('existing dibang controller rules preserve external prohibition and internal manual exception', () => {
assert.deepEqual(resolveStopDibangDefaults('外部','load'),{generateDibang:0,dibangDirection:'none'})
assert.deepEqual(resolveStopDibangDefaults('安庆市山峡化工有限公司','load'),{generateDibang:1,dibangDirection:'outbound'})
assert.equal(canManualToggleStopDibang('外部','皖H21522'),false)
assert.equal(canManualToggleStopDibang('安庆市山峡化工有限公司','皖H21522'),true)
})
test('actual internal catalog callback cannot append into a replaced mobile load segment', async () => {
const loadForm:any={value:{products:[{productId:'old'}]}}, productSelectorTarget={value:{side:'load',deliveryIndex:0}}, isPageVariant={value:true}
const fn=productionFunction('components/business/pl-plan-form-dialog/index.vue','handleProductConfirm',{loadForm,productSelectorTarget,isPageVariant,deliveryForms:{value:[]},pageLoadGeneration:1,isPageSubmitCurrent:()=>true,freightCalc:{resetFreightCalc:()=>{}},mobileStopIdentity})
const pending=fn([]);loadForm.value={products:[{productId:'new'}]};await pending
assert.deepEqual(loadForm.value.products,[{productId:'new'}])
})
test('mobile arrange quantity retry invalidates failed cache before existing loader', async () => {
const cache={value:{key:null}}, calls:string[]=[]
const fn=productionFunction('components/business/pl-plan-stop-product-table/index.vue','retryMobileArrangeQty',{arrangeQtyCache:cache,cacheKey:()=> 'key',invalidateArrangeQtyCache:(k:string)=>calls.push(k),ensureArrangeQty:async()=>({availableQty:3})})
assert.deepEqual(await fn({}),{availableQty:3});assert.deepEqual(calls,['key']);assert.equal('key' in cache.value,false)
})
test('all unrelated existing child controller functions remain byte-identical to R3 baseline', () => {
const allowed:Record<string,string[]>={
'pl-plan-delivery-section':['removeDelivery','applyDeliveryStopPref'],
'pl-plan-contract-multi-select':['toggleExpand','enrichContractProducts','loadDetails','handleConfirmSingle']
}
for(const name of components){
const path=`components/business/${name}/index.vue`
const parseAst=(s:string)=>ts.createSourceFile('controller.ts',parse(s).descriptor.scriptSetup!.content,ts.ScriptTarget.Latest,true)
const before=parseAst(readFileSync(`/tmp/mobile-r3-before/${path}`,'utf8')), after=parseAst(read(path))
for(const node of before.statements){
if(!ts.isFunctionDeclaration(node)||!node.name)continue
if(allowed[name]?.includes(node.name.text))continue
const next=after.statements.find(n=>ts.isFunctionDeclaration(n)&&n.name?.text===node.name!.text)
assert.ok(next,`${name}.${node.name.text}`)
assert.equal(next.getText(after),node.getText(before),`${name}.${node.name.text}`)
}
}
})
test('teleported plan picker explicitly owns primary button tokens',()=>{
const styles=parse(read('views/m/pl/plan/PlanFormPickerSheets.vue')).descriptor.styles.map(s=>s.content).join('\n')
assert.match(styles,/--van-button-primary-background:\s*#0a9f6d/)
assert.match(styles,/--van-button-primary-border-color:\s*#0a9f6d/)
})
for(const product of [null,{}, {id:'wrong',aliases:[],brands:[]},{id:'p'}]) test(`metadata refresh rejects incomplete or wrong-product response ${JSON.stringify(product)}`,async()=>{
const row={productId:'p'}, calls:any[]=[]
const fn=productionFunction('views/m/pl/plan/MobilePlanProductCards.vue','refreshMetadata',{c:{props:{products:[row]}},fetchProductDetail:async()=>({data:product}),metadata:{saved:(...args:any[])=>calls.push(args),capture:()=>()=>true},metadataRefreshVersion:0})
await fn(row);assert.equal(calls.length,0)
})