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

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