import assert from 'node:assert/strict' import { readFileSync, existsSync } from 'node:fs' import test from 'node:test' import { execFileSync } from 'node:child_process' import { parse, compileScript, compileTemplate } from '@vue/compiler-sfc' import ts from 'typescript' // d104ef86d introduced the R3 mobile child components and this regression suite. // Its parent preserves the actual desktop implementation before that change. const beforeR3 = '5b00081d83dba5660a83fb713da5003fc217a95a' const readBeforeR3 = (path: string) => execFileSync('git', ['show', `${beforeR3}:web-admin/src/${path}`], { cwd: new URL('../..', import.meta.url), encoding: 'utf8' }) 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(readBeforeR3(path)).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, / { 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) { 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(), 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={ '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(readBeforeR3(path)), 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) })