mobile-pl-plan-stops.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174
  1. import assert from 'node:assert/strict'
  2. import { readFileSync, existsSync } from 'node:fs'
  3. import test from 'node:test'
  4. import { parse, compileScript, compileTemplate } from '@vue/compiler-sfc'
  5. import ts from 'typescript'
  6. const root = new URL('../src/', import.meta.url)
  7. const read = (path: string) => readFileSync(new URL(path, root), 'utf8')
  8. const components = ['pl-plan-load-section','pl-plan-delivery-section','pl-plan-stop-product-table','pl-plan-contract-multi-select','pl-address-with-library']
  9. for (const component of components) test(`${component}: mobile opt-in root and unchanged desktop template`, () => {
  10. const path = `components/business/${component}/index.vue`
  11. const d = parse(read(path)).descriptor
  12. const roots = d.template!.ast.children.filter((n: any) => n.type === 1) as any[]
  13. assert.match(roots[0].loc.source, /v-if="mobile/)
  14. const baseline = parse(readFileSync(`/tmp/mobile-r3-before/${path}`, 'utf8')).descriptor
  15. const old = (baseline.template!.ast.children.find((n: any) => n.type === 1) as any).loc.source
  16. assert.equal(roots[1].loc.source.replace(/\s+v-else(?=\s|>)/, ''), old)
  17. })
  18. for (const component of ['MobilePlanLoadSection','MobilePlanDeliverySection','MobilePlanProductCards','MobilePlanContractCards','PlanFormPickerSheets']) test(`${component}: mobile template compiles without desktop controls`, () => {
  19. const path = `views/m/pl/plan/${component}.vue`
  20. assert.ok(existsSync(new URL(path, root)), 'independent mobile component exists')
  21. const d = parse(read(path)).descriptor
  22. assert.doesNotMatch(d.template!.content, /<el-(?:input|select|table|button|date-picker)\b/)
  23. compileScript(d, { id: component })
  24. assert.deepEqual(compileTemplate({source:d.template!.content, filename:path,id:component}).errors, [])
  25. })
  26. test('actual scroll controller accepts fractional layout position within one pixel', () => {
  27. const source = parse(read('views/m/pl/plan/MobilePlPlanForm.vue')).descriptor.scriptSetup!.content
  28. const ast = ts.createSourceFile('scroll.ts', source, ts.ScriptTarget.Latest, true)
  29. const fn = ast.statements.find(n => ts.isFunctionDeclaration(n) && n.name?.text === 'syncScroll')!
  30. const active = { value: 'load' }
  31. const run = new Function('syncViewport','clickLock','navRef','sections','window','document','active',ts.transpile(fn.getText(ast))+';return syncScroll')
  32. 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)()
  33. assert.equal(active.value,'delivery')
  34. })
  35. function productionFunction(path: string, name: string, bindings: Record<string, any>) {
  36. const ast = ts.createSourceFile('controller.ts', parse(read(path)).descriptor.scriptSetup!.content, ts.ScriptTarget.Latest, true)
  37. const fn = ast.statements.find(n => ts.isFunctionDeclaration(n) && n.name?.text === name)
  38. assert.ok(fn, `production function ${name}`)
  39. return new Function(...Object.keys(bindings), ts.transpile(fn.getText(ast)) + `;return ${name}`)(...Object.values(bindings))
  40. }
  41. test('mobile delivery preference response follows the original segment through reorder', async () => {
  42. let resolve: (v: any) => void = () => {}
  43. const a = { customerId: 'same', products: [], address: '' }, b = { customerId: 'same', products: [], address: 'B' }
  44. const props = { mobileSheet: true, modelValue: [a, b] }
  45. const calls: any[] = []
  46. const fn = productionFunction('components/business/pl-plan-delivery-section/index.vue', 'applyDeliveryStopPref', {
  47. props, customerStopPrefApi: { get: () => new Promise(r => { resolve = r }) }, pickBxData: (x: any) => x,
  48. patchDelivery: (i: number, value: any) => calls.push([i,value]),
  49. mobileStopIdentity: (x: any) => x.products
  50. })
  51. const pending = fn(0, 'same'); props.modelValue = [b, a]
  52. resolve({ address: 'A' }); await pending
  53. assert.deepEqual(calls, [[1,{address:'A'}]])
  54. })
  55. test('last mobile delivery cannot be removed by exposed controller', () => {
  56. const row = { customerId:'A' }, calls: any[]=[]
  57. 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}})
  58. fn(0); assert.deepEqual(calls,[])
  59. })
  60. test('quantity input retains decimal intermediate text while committing three decimals on blur', () => {
  61. const quantityDrafts = new Map<any,string>(), row:any={planQty:0}
  62. const path='views/m/pl/plan/MobilePlanProductCards.vue'
  63. const set=productionFunction(path,'setQuantity',{quantityDrafts,c:{props:{editable:true}}})
  64. for(const input of ['1','1.','1.1','1.12','1.125']) { set(row,input); assert.equal(quantityDrafts.get(row),input) }
  65. assert.equal(row.planQty,1.125)
  66. })
  67. test('mobile metadata persistence blocks save until authoritative refresh and ignores removed rows', async () => {
  68. const path = new URL('../src/views/m/pl/plan/mobilePlanMetadata.ts', import.meta.url)
  69. assert.ok(existsSync(path),'mobile metadata guard exists')
  70. const {createMobilePlanMetadata} = await import(path.href)
  71. const a:any={productId:'p',aliasOptions:['old']}, b:any={productId:'p',aliasOptions:['old']}
  72. let rows=[a,b]
  73. const guard=createMobilePlanMetadata(()=>rows)
  74. guard.persisted(a); assert.equal(guard.ready(),false)
  75. guard.saved(a,{aliases:[{aliasName:'fresh'}],brands:[]})
  76. assert.equal(guard.ready(),true); assert.deepEqual(b.aliasOptions,['fresh'])
  77. rows=[]; guard.persisted(a); assert.equal(guard.ready(),true)
  78. })
  79. test('obsolete mobile contract detail response cannot replace a newer customer snapshot', async () => {
  80. let resolve:(v:any)=>void=()=>{}
  81. const props:any={mobile:true,customerId:'A',modelValue:[{contractId:'c1'}]}, detailRows:any={value:[]}, loadingDetails={value:false}
  82. 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}})
  83. const pending=fn(['c1']); props.customerId='B'; detailRows.value=[{id:'c2'}]
  84. resolve({id:'c1',products:[]});await pending
  85. assert.deepEqual(detailRows.value,[{id:'c2'}])
  86. })
  87. import { buildLoadPayload, buildDeliveriesPayload, createEmptyLoadForm, createEmptyDeliveryForm, resolveStopDibangDefaults, canManualToggleStopDibang } from '../src/utils/pl-plan-stop-form'
  88. import { collectStopContractIds, unionContractProductRows, allowedPackageSpecsForStopProduct, normalizePackageSpec } from '../src/utils/pl-plan-contract-product'
  89. import { isPlInternalCustomerName } from '../src/utils/pl-internal-customer'
  90. import { findFirstStopProductArrangeGap } from '../src/utils/pl-plan-stop-product-arrange-required'
  91. import { mobileStopIdentity } from '../src/views/m/pl/plan/mobileStopIdentity'
  92. test('payload keeps two same-product contract rows and original identities; mobile token never serializes', () => {
  93. 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}]}
  94. const identity=mobileStopIdentity(load)
  95. assert.equal(mobileStopIdentity({...load}),identity)
  96. const payload=buildLoadPayload(load)
  97. assert.equal(payload.id,'stop');assert.equal(payload.products.length,2)
  98. assert.deepEqual(payload.products.map(p=>[p.planProductId,p.contractId,p.contractProductId,p.packageSpec]),[['pp1','a','ca','桶'],['pp2','b','cb','袋']])
  99. assert.equal(Object.getOwnPropertySymbols(payload).length,0)
  100. const a:any={...createEmptyDeliveryForm(),id:'delivery',contact:'19900001111',notes:'保留',products:load.products}
  101. mobileStopIdentity(a)
  102. assert.equal(buildDeliveriesPayload([a])[0].contact,'19900001111')
  103. assert.equal(Object.getOwnPropertySymbols(buildDeliveriesPayload([a])[0]).length,0)
  104. })
  105. test('actual formal contract validator rejects external no-contract and permits exact internal names', () => {
  106. const loadForm={value:{customerName:'外部',contracts:[]}}, deliveryForms={value:[]}, messages:string[]=[]
  107. const bindings={loadForm,deliveryForms,isPlInternalCustomerName,ElMessage:{warning:(s:string)=>messages.push(s)}}
  108. const stopHasSelectedContract=productionFunction('components/business/pl-plan-form-dialog/index.vue','stopHasSelectedContract',{})
  109. const valid=productionFunction('components/business/pl-plan-form-dialog/index.vue','validateStopContractsRequired',{...bindings,stopHasSelectedContract})
  110. assert.equal(valid(),false)
  111. loadForm.value.customerName='安庆市山峡化工有限公司';assert.equal(valid(),true)
  112. loadForm.value.customerName='安徽云栋化工有限公司';assert.equal(valid(),true)
  113. loadForm.value.customerName='安庆市山峡化工有限公司分公司';assert.equal(valid(),false)
  114. })
  115. test('actual formal packaging validator distinguishes same product contract line packaging', () => {
  116. const product:any={productId:'p',contractId:'a',contractProductId:'ca',productName:'产品',packageSpec:'袋'}
  117. const loadForm={value:{contracts:[{contractId:'a'},{contractId:'b'}],products:[product]}}
  118. const contractProductRowsCache={value:{a:[{id:'ca',productId:'p',contractId:'a',packageSpec:'桶'}],b:[{id:'cb',productId:'p',contractId:'b',packageSpec:'袋'}]}}
  119. const valid=productionFunction('components/business/pl-plan-form-dialog/index.vue','validateContractPackageSpecs',{loadForm,deliveryForms:{value:[]},contractProductRowsCache,collectStopContractIds,unionContractProductRows,allowedPackageSpecsForStopProduct,normalizePackageSpec,ElMessage:{error:()=>{}}})
  120. assert.equal(valid(),false);product.packageSpec='桶';assert.equal(valid(),true)
  121. })
  122. test('zero quantity remains serializable for drafts but actual arrange validation rejects it', () => {
  123. const row:any={productId:'p',unit:'吨',packageSpec:'桶',deliveryMethod:'送货',planQty:0}
  124. assert.equal(buildLoadPayload({...createEmptyLoadForm(),products:[row]}).products[0].planQty,0)
  125. assert.match(findFirstStopProductArrangeGap({loadProducts:[row]})!,/计划数量/)
  126. row.planQty=1.125;assert.equal(findFirstStopProductArrangeGap({loadProducts:[row]}),null)
  127. })
  128. test('existing dibang controller rules preserve external prohibition and internal manual exception', () => {
  129. assert.deepEqual(resolveStopDibangDefaults('外部','load'),{generateDibang:0,dibangDirection:'none'})
  130. assert.deepEqual(resolveStopDibangDefaults('安庆市山峡化工有限公司','load'),{generateDibang:1,dibangDirection:'outbound'})
  131. assert.equal(canManualToggleStopDibang('外部','皖H21522'),false)
  132. assert.equal(canManualToggleStopDibang('安庆市山峡化工有限公司','皖H21522'),true)
  133. })
  134. test('actual internal catalog callback cannot append into a replaced mobile load segment', async () => {
  135. const loadForm:any={value:{products:[{productId:'old'}]}}, productSelectorTarget={value:{side:'load',deliveryIndex:0}}, isPageVariant={value:true}
  136. const fn=productionFunction('components/business/pl-plan-form-dialog/index.vue','handleProductConfirm',{loadForm,productSelectorTarget,isPageVariant,deliveryForms:{value:[]},pageLoadGeneration:1,isPageSubmitCurrent:()=>true,freightCalc:{resetFreightCalc:()=>{}},mobileStopIdentity})
  137. const pending=fn([]);loadForm.value={products:[{productId:'new'}]};await pending
  138. assert.deepEqual(loadForm.value.products,[{productId:'new'}])
  139. })
  140. test('mobile arrange quantity retry invalidates failed cache before existing loader', async () => {
  141. const cache={value:{key:null}}, calls:string[]=[]
  142. 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})})
  143. assert.deepEqual(await fn({}),{availableQty:3});assert.deepEqual(calls,['key']);assert.equal('key' in cache.value,false)
  144. })
  145. test('all unrelated existing child controller functions remain byte-identical to R3 baseline', () => {
  146. const allowed:Record<string,string[]>={
  147. 'pl-plan-delivery-section':['removeDelivery','applyDeliveryStopPref'],
  148. 'pl-plan-contract-multi-select':['toggleExpand','enrichContractProducts','loadDetails','handleConfirmSingle']
  149. }
  150. for(const name of components){
  151. const path=`components/business/${name}/index.vue`
  152. const parseAst=(s:string)=>ts.createSourceFile('controller.ts',parse(s).descriptor.scriptSetup!.content,ts.ScriptTarget.Latest,true)
  153. const before=parseAst(readFileSync(`/tmp/mobile-r3-before/${path}`,'utf8')), after=parseAst(read(path))
  154. for(const node of before.statements){
  155. if(!ts.isFunctionDeclaration(node)||!node.name)continue
  156. if(allowed[name]?.includes(node.name.text))continue
  157. const next=after.statements.find(n=>ts.isFunctionDeclaration(n)&&n.name?.text===node.name!.text)
  158. assert.ok(next,`${name}.${node.name.text}`)
  159. assert.equal(next.getText(after),node.getText(before),`${name}.${node.name.text}`)
  160. }
  161. }
  162. })
  163. test('teleported plan picker explicitly owns primary button tokens',()=>{
  164. const styles=parse(read('views/m/pl/plan/PlanFormPickerSheets.vue')).descriptor.styles.map(s=>s.content).join('\n')
  165. assert.match(styles,/--van-button-primary-background:\s*#0a9f6d/)
  166. assert.match(styles,/--van-button-primary-border-color:\s*#0a9f6d/)
  167. })
  168. for(const product of [null,{}, {id:'wrong',aliases:[],brands:[]},{id:'p'}]) test(`metadata refresh rejects incomplete or wrong-product response ${JSON.stringify(product)}`,async()=>{
  169. const row={productId:'p'}, calls:any[]=[]
  170. 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})
  171. await fn(row);assert.equal(calls.length,0)
  172. })