mobile-pl-plan-load-state.test.ts 45 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183
  1. import assert from 'node:assert/strict'
  2. import { readFileSync } from 'node:fs'
  3. import test from 'node:test'
  4. import { effectScope, nextTick, ref, watch } from 'vue'
  5. import { parse } from '@vue/compiler-sfc'
  6. import ts from 'typescript'
  7. import { preserveMobileCopyAssociations, assertMobilePersistenceCapability } from '../src/views/m/pl/plan/mobilePlanPersistence'
  8. import { mobileStopIdentity } from '../src/views/m/pl/plan/mobileStopIdentity'
  9. import * as brand from '../src/utils/crm-product-brand'
  10. import { collectStopContractIds, filterProductsByAllowedProductIds } from '../src/utils/pl-plan-contract-product'
  11. import {
  12. describeUntaggedExternalStopProducts,
  13. retainStopProductsForContracts
  14. } from '../src/utils/pl-plan-stop-form'
  15. const source = readFileSync(
  16. new URL('../src/components/business/pl-plan-form-dialog/index.vue', import.meta.url),
  17. 'utf8'
  18. )
  19. const descriptor = parse(source).descriptor
  20. const ast = ts.createSourceFile(
  21. 'form.ts',
  22. descriptor.scriptSetup!.content,
  23. ts.ScriptTarget.Latest,
  24. true
  25. )
  26. function declaration(name: string) {
  27. return ast.statements
  28. .find(
  29. (node) =>
  30. (ts.isFunctionDeclaration(node) && node.name?.text === name) ||
  31. (ts.isVariableStatement(node) &&
  32. node.declarationList.declarations.some((d) => d.name.getText(ast) === name))
  33. )
  34. ?.getText(ast)
  35. }
  36. function deferred<T = any>() {
  37. let resolve!: (value: T) => void
  38. let reject!: (reason: any) => void
  39. const promise = new Promise<T>((yes, no) => {
  40. resolve = yes
  41. reject = no
  42. })
  43. return { promise, resolve, reject }
  44. }
  45. function harness(
  46. options: {
  47. page?: boolean
  48. copy?: boolean
  49. newPlan?: boolean
  50. get?: any
  51. master?: any
  52. contract?: any
  53. enrich?: any
  54. realDependencies?: boolean
  55. realArrange?: boolean
  56. overrides?: Record<string, any>
  57. } = {}
  58. ) {
  59. const writes: any[] = []
  60. const events: any[] = []
  61. const invalidations: string[] = []
  62. const noop = () => {}
  63. const bindings: Record<string, any> = {
  64. mobileStopIdentity,
  65. preserveMobileCopyAssociations,
  66. buildMobileProductLinks: () => ({}),
  67. assertMobilePersistenceCapability: () => {},
  68. ref,
  69. Error,
  70. String,
  71. Object,
  72. Promise,
  73. Set,
  74. Number,
  75. props: {
  76. planId: options.copy || options.newPlan ? undefined : 'plan-1',
  77. copyFromPlanId: options.copy ? 'plan-1' : undefined
  78. },
  79. isPageVariant: ref(options.page !== false),
  80. isEdit: ref(!options.copy && !options.newPlan),
  81. detailLoading: ref(false),
  82. submitLoading: ref(false),
  83. suppressAddressMutex: ref(false),
  84. suppressStopProductSync: ref(false),
  85. formData: { belongsCompany: 'shanxia', createDate: '2026-10-21', products: [] },
  86. loadForm: ref({ products: [], contracts: [] }),
  87. deliveryForms: ref([]),
  88. loadedPlanSnapshot: ref(null),
  89. loadedTransportStatus: ref('pending'),
  90. loadedFollowStatus: ref(''),
  91. selectedDriverId: ref(''),
  92. selectedEscortId: ref(''),
  93. vehicleBundle: ref(null),
  94. contractProductIdsCache: ref({}),
  95. contractProductRowsCache: ref({}),
  96. contractLinesCache: ref({}),
  97. loadContractProductIds: ref([]),
  98. vehicleList: ref([]),
  99. carrierList: ref([]),
  100. driverList: ref([]),
  101. escortList: ref([]),
  102. customerOptions: ref([]),
  103. customerOptionsLoading: ref(false),
  104. vehicleLoading: ref(false),
  105. footerMoreOpen: ref(false),
  106. planApi: {
  107. mobileProductCapability: async () => ({ mobileProductMetaVersion: 1 }),
  108. get:
  109. options.get ||
  110. (async () => ({
  111. data: { id: 'plan-1', belongsCompany: 'shanxia', remark: '原计划', products: [] }
  112. })),
  113. update: async (...args: any[]) => {
  114. writes.push(['update', ...args])
  115. return { id: 'plan-1' }
  116. },
  117. createFull: async (...args: any[]) => {
  118. writes.push(['create', ...args])
  119. return { id: 'new-plan' }
  120. }
  121. },
  122. invalidateContractDetailCache: (id: string) => invalidations.push(id),
  123. getContractDetailCached: options.contract || (async () => ({ id: 'c1', products: [] })),
  124. enrichPlanProductsHazardFromCrm: options.enrich || (async (rows: any) => rows),
  125. enrichPlanProductLinesAlias: async (rows: any) => rows,
  126. mapPlanProductToFormRow: (row: any) => ({ ...row }),
  127. mapPlanProductToCopyFormRow: (row: any) => ({ ...row, id: undefined }),
  128. collectStopContractIds,
  129. describeUntaggedExternalStopProducts,
  130. retainStopProductsForContracts,
  131. collectContractProductRows: (rows: any) => rows,
  132. collectContractProductIds: (rows: any[]) => rows.map((row) => row.productId),
  133. unionContractProductIds: (cache: any, ids: string[]) => ids.flatMap((id) => cache[id] || []),
  134. resetForm: () => {
  135. bindings.formData.remark = ''
  136. bindings.formData.products = []
  137. bindings.loadedPlanSnapshot.value = null
  138. },
  139. applyStopsFromPlan: (data: any) => {
  140. bindings.loadForm.value = data.load || { products: [], contracts: [] }
  141. bindings.deliveryForms.value = data.deliveries || []
  142. },
  143. syncCommittedPartyIds: noop,
  144. bootstrapStopsFromPlanProducts: noop,
  145. syncSelectedCustomerOptions: noop,
  146. todayYmd: () => '2026-10-21',
  147. formatDate: () => '',
  148. resolveCurrentUserId: () => 'user',
  149. resolveCurrentUserName: () => '用户',
  150. freightCalc: { resetFreightCalc: noop, freightAmount: ref(null) },
  151. ensureSelectedVehicleOption: async () => {},
  152. ensureSelectedDriverOption: async () => {},
  153. ensureSelectedEscortOption: async () => {},
  154. ensureSelectedCarrierOption: async () => {},
  155. handleVehicleChange: async () => {},
  156. restoreFreightFromTransport: async () => {},
  157. reapplyStopDibangByVehiclePlate: noop,
  158. nextTick: async () => {},
  159. mobileMetadata: { ready: () => true },
  160. ElMessage: { error: noop, warning: noop, success: noop },
  161. emit: (...args: any[]) => events.push(args),
  162. innerVisible: ref(true),
  163. basicFormRef: ref({ validate: async () => true, validateField: async () => {} }),
  164. mirrorPlanProductsFromLoad: noop,
  165. buildPlanCommonPayload: () => ({
  166. remark: bindings.formData.remark,
  167. products: bindings.formData.products
  168. }),
  169. pickBxData: (data: any) => data.data ?? data,
  170. validateBusinessRules: () => true,
  171. validateSingleContractPerStop: () => true,
  172. validateContractPackageSpecs: () => true,
  173. validateStopContractsRequired: () => true,
  174. getArrangePrerequisiteGaps: () => [],
  175. validateStopProductsForArrange: () => true,
  176. showPlanSaveWarnings: noop,
  177. performArrangeAfterSave: async (id: string) => {
  178. writes.push(['arrange', id])
  179. return true
  180. },
  181. loadMasterData: options.master || (async () => {}),
  182. vehicleApi: { list: async () => [], get: async (id: string) => ({ id }) },
  183. carrierApi: { list: async () => [], get: async (id: string) => ({ id }) },
  184. personnelApi: { list: async () => [], get: async (id: string) => ({ id }) },
  185. fetchCustomerOptions: async () => [],
  186. extractCustomerOptions: (data: any) => data,
  187. pickBxList: (data: any) => data.data ?? data,
  188. searchVehicles: async () => {},
  189. searchCarriers: async () => {},
  190. searchDrivers: async () => {},
  191. searchEscorts: async () => {},
  192. loadDefaultCustomerOptions: async () => {},
  193. loadVehicleViewBundle: async () => ({ detail: {}, personnelList: [] }),
  194. transportApi: { get: async () => ({}) },
  195. resolveDefaultDriver: noop,
  196. resolveDefaultEscort: noop,
  197. buildMissingHazardCertReadyMessage: () => '',
  198. ElMessageBox: { alert: async () => {} },
  199. createArrangePresenter: () => async () => {},
  200. planHasAnyDibangSelected: () => false,
  201. loadArrangeHistoricalFlags: async () => ({}),
  202. confirmBeforePlanArrange: async () => {},
  203. buildArrangePayloadFromPlanForm: () => ({}),
  204. submitPlanArrange: async (id: string) => {
  205. writes.push(['arrange', id])
  206. },
  207. getUnitLabel: () => '',
  208. ...options.overrides
  209. }
  210. const names = [
  211. 'mobileSavedPlanId',
  212. 'mobileCreateUncertain',
  213. 'pageReady',
  214. 'pageLoadError',
  215. 'pageLoadGeneration',
  216. 'pageDisposed',
  217. 'isPageLoadCurrent',
  218. 'assertPageLoadCurrent',
  219. 'failPageLoad',
  220. 'invalidatePageLoad',
  221. 'validateLoadedPagePlan',
  222. 'handleOpened',
  223. 'ensureMobilePersistenceCapability',
  224. 'loadPlanForEdit',
  225. 'loadPlanForCopy',
  226. 'fetchContractProductIds',
  227. 'prefetchStopContractProducts',
  228. 'handleSubmit',
  229. 'isPageSubmitCurrent',
  230. 'pageLoadErrorMessage'
  231. ]
  232. names.push('ensurePageSelectedOption')
  233. if (options.realDependencies) {
  234. names.push('loadMasterData', 'handleVehicleChange', 'restoreFreightFromTransport')
  235. for (const name of ['loadMasterData', 'handleVehicleChange', 'restoreFreightFromTransport'])
  236. delete bindings[name]
  237. }
  238. if (options.realArrange) {
  239. names.push('performArrangeAfterSave')
  240. delete bindings.performArrangeAfterSave
  241. }
  242. const present = names.filter((name) => declaration(name))
  243. const code = ts.transpile(present.map((name) => declaration(name)).join('\n'), {
  244. target: ts.ScriptTarget.ESNext
  245. })
  246. const result = new Function(
  247. ...Object.keys(bindings),
  248. code + '; return {' + present.join(',') + '}'
  249. )(...Object.values(bindings))
  250. return { ...bindings, ...result, writes, events, invalidations }
  251. }
  252. async function blocked(h: ReturnType<typeof harness>) {
  253. for (const mode of ['draft', 'save', 'saveAndArrange', 'rearrange']) await h.handleSubmit(mode)
  254. assert.deepEqual(h.writes, [], 'failed initialization must never write or arrange')
  255. }
  256. for (const status of [404, 500])
  257. test(`mobile edit GET ${status} stays blocked`, async () => {
  258. const h = harness({
  259. get: async () => {
  260. throw new Error(`HTTP ${status}`)
  261. }
  262. })
  263. await h.handleOpened().catch(() => {})
  264. await blocked(h)
  265. assert.equal(h.pageReady.value, false)
  266. assert.match(h.pageLoadError.value, /404|500/)
  267. assert.equal(h.detailLoading.value, false)
  268. })
  269. test('mobile copy GET failure remains visible and blocked', async () => {
  270. const h = harness({
  271. copy: true,
  272. get: async () => {
  273. throw new Error('copy failed')
  274. }
  275. })
  276. await h.handleOpened().catch(() => {})
  277. await blocked(h)
  278. assert.ok(h.pageLoadError.value)
  279. assert.equal(
  280. h.events.some((event) => event[0] === 'update:visible'),
  281. false
  282. )
  283. })
  284. for (const data of [{}, { id: 'different' }, null])
  285. test(`mobile rejects invalid plan ${JSON.stringify(data)}`, async () => {
  286. const h = harness({ get: async () => ({ data }) })
  287. await h.handleOpened().catch(() => {})
  288. await blocked(h)
  289. assert.ok(h.pageLoadError.value)
  290. })
  291. test('new mobile base dependency failure cannot create', async () => {
  292. const h = harness({
  293. newPlan: true,
  294. master: async () => {
  295. throw new Error('base failed')
  296. }
  297. })
  298. await h.handleOpened().catch(() => {})
  299. await blocked(h)
  300. })
  301. test('contract failure preserves historical products, and formal save waits for a contract line', async () => {
  302. let failed = true
  303. const h = harness({
  304. get: async () => ({
  305. id: 'plan-1',
  306. belongsCompany: 'shanxia',
  307. remark: '原计划',
  308. products: [{ productId: 'p1' }],
  309. load: { contracts: [{ contractId: 'c1' }], products: [{ productId: 'p1', planQty: 12 }] }
  310. }),
  311. contract: async () => {
  312. if (failed) throw new Error('contract failed')
  313. return { id: 'c1', products: [{ productId: 'p1' }] }
  314. }
  315. })
  316. await h.handleOpened().catch(() => {})
  317. await blocked(h)
  318. assert.deepEqual(h.contractProductIdsCache.value, {})
  319. assert.equal(h.loadForm.value.products[0].planQty, 12)
  320. failed = false
  321. await h.handleOpened()
  322. assert.equal(h.pageReady.value, true)
  323. await h.handleSubmit('save')
  324. assert.deepEqual(h.writes, [])
  325. h.loadForm.value.products[0].contractId = 'c1'
  326. h.loadForm.value.products[0].contractProductId = 'line-1'
  327. await h.handleSubmit('save')
  328. assert.deepEqual(h.writes, [
  329. ['update', 'plan-1', { remark: '原计划', products: [{ productId: 'p1' }], saveMode: 'formal' }]
  330. ])
  331. })
  332. for (const lateError of [false, true])
  333. test(`retry ignores old ${lateError ? 'error' : 'success'} and finally`, async () => {
  334. const old = deferred()
  335. const fresh = deferred()
  336. let count = 0
  337. const h = harness({ get: () => (++count === 1 ? old.promise : fresh.promise) })
  338. const first = h.handleOpened().catch(() => {})
  339. while (count < 1) await Promise.resolve()
  340. const second = h.handleOpened()
  341. while (count < 2) await Promise.resolve()
  342. if (lateError) old.reject(new Error('old error'))
  343. else old.resolve({ id: 'plan-1', remark: 'stale' })
  344. await first
  345. assert.equal(h.detailLoading.value, true)
  346. assert.equal(h.formData.remark, '')
  347. assert.equal(h.pageLoadError.value, '')
  348. fresh.resolve({ id: 'plan-1', remark: 'fresh', products: [] })
  349. await second
  350. assert.equal(h.formData.remark, 'fresh')
  351. assert.equal(h.pageReady.value, true)
  352. })
  353. test('unmount invalidates pending detail without late ref writes or loading events', async () => {
  354. const request = deferred()
  355. const h = harness({ get: () => request.promise })
  356. const work = h.handleOpened()
  357. await Promise.resolve()
  358. await Promise.resolve()
  359. h.invalidatePageLoad()
  360. const before = JSON.stringify(h.formData)
  361. request.resolve({ id: 'plan-1', remark: 'stale' })
  362. await work
  363. assert.equal(JSON.stringify(h.formData), before)
  364. assert.equal(h.pageReady.value, false)
  365. })
  366. test('desktop retains copy failure close, failed contract empty cache and draft save behavior', async () => {
  367. const h = harness({
  368. page: false,
  369. copy: true,
  370. get: async () => {
  371. throw new Error('404')
  372. },
  373. contract: async () => {
  374. throw new Error('offline')
  375. }
  376. })
  377. await h.handleOpened()
  378. assert.ok(h.events.some((event) => event[0] === 'update:visible' && event[1] === false))
  379. assert.deepEqual(await h.fetchContractProductIds('c1'), [])
  380. assert.deepEqual(h.contractProductIdsCache.value, { c1: [] })
  381. await h.handleSubmit('draft')
  382. assert.equal(h.writes[0][0], 'create')
  383. })
  384. test('page template gates form and write actions on readiness and exposes retry/return', () => {
  385. assert.match(descriptor.template!.content, /v-if="isPageVariant && pageLoadError"/)
  386. assert.match(descriptor.template!.content, /v-show="!isPageVariant \|\| pageReady"/)
  387. assert.match(descriptor.template!.content, /v-if="isPageVariant && pageReady"/)
  388. assert.match(descriptor.template!.content, /重新加载/)
  389. assert.match(source, /onBeforeUnmount\(invalidatePageLoad\)/)
  390. })
  391. for (const dependency of ['vehicleApi', 'carrierApi', 'personnelApi', 'fetchCustomerOptions']) {
  392. test(`real mobile base loader fails closed for ${dependency}`, async () => {
  393. const fail = async () => {
  394. throw new Error(`${dependency} failed`)
  395. }
  396. const overrides =
  397. dependency === 'fetchCustomerOptions'
  398. ? { fetchCustomerOptions: fail }
  399. : { [dependency]: { list: fail } }
  400. const h = harness({ newPlan: true, realDependencies: true, overrides })
  401. await h.handleOpened()
  402. await blocked(h)
  403. assert.match(h.pageLoadError.value, /failed/)
  404. })
  405. }
  406. test('late base dependency cannot replace new option lists', async () => {
  407. const old = deferred()
  408. let count = 0
  409. const h = harness({
  410. newPlan: true,
  411. realDependencies: true,
  412. overrides: {
  413. vehicleApi: { list: () => (++count === 1 ? old.promise : Promise.resolve([{ id: 'fresh' }])) }
  414. }
  415. })
  416. const first = h.handleOpened()
  417. while (count < 1) await Promise.resolve()
  418. await h.handleOpened()
  419. old.resolve([{ id: 'old' }])
  420. await first
  421. assert.deepEqual(h.vehicleList.value, [{ id: 'fresh' }])
  422. assert.equal(h.pageReady.value, true)
  423. })
  424. test('late product enrichment cannot overwrite retry products or release new suppression flags', async () => {
  425. const old = deferred()
  426. const fresh = deferred()
  427. let count = 0
  428. const h = harness({ enrich: () => (++count === 1 ? old.promise : fresh.promise) })
  429. const first = h.handleOpened()
  430. while (count < 1) await Promise.resolve()
  431. const second = h.handleOpened()
  432. while (count < 2) await Promise.resolve()
  433. old.resolve([{ productId: 'old' }])
  434. await first
  435. assert.equal(h.suppressStopProductSync.value, true)
  436. assert.deepEqual(h.formData.products, [])
  437. fresh.resolve([{ productId: 'fresh' }])
  438. await second
  439. assert.deepEqual(h.formData.products, [{ productId: 'fresh' }])
  440. })
  441. test('late vehicle bundle cannot overwrite retry refs or clear the new vehicle loading flag', async () => {
  442. const old = deferred()
  443. const fresh = deferred()
  444. let count = 0
  445. const h = harness({
  446. realDependencies: true,
  447. get: async () => ({ id: 'plan-1', vehicleId: 'v1' }),
  448. overrides: { loadVehicleViewBundle: () => (++count === 1 ? old.promise : fresh.promise) }
  449. })
  450. const first = h.handleOpened()
  451. while (count < 1) await Promise.resolve()
  452. const second = h.handleOpened()
  453. while (count < 2) await Promise.resolve()
  454. old.resolve({ detail: { carrierId: 'old' }, personnelList: [] })
  455. await first
  456. assert.equal(h.vehicleLoading.value, true)
  457. assert.equal(h.vehicleBundle.value, null)
  458. fresh.resolve({ detail: { carrierId: 'fresh' }, personnelList: [] })
  459. await second
  460. assert.equal(h.formData.carrierId, 'fresh')
  461. })
  462. test('late contract success cannot populate the current generation cache', async () => {
  463. const old = deferred()
  464. const h = harness({ contract: () => old.promise })
  465. const fetch = h.fetchContractProductIds('c1').catch(() => {})
  466. await h.handleOpened()
  467. old.resolve({ products: [{ productId: 'old' }] })
  468. await fetch
  469. assert.deepEqual(h.contractProductIdsCache.value, {})
  470. assert.equal(h.pageLoadError.value, '')
  471. })
  472. test('successful mobile copy clears source ids and transport people before creating', async () => {
  473. const original = {
  474. id: 'plan-1',
  475. belongsCompany: 'shanxia',
  476. vehicleId: 'v1',
  477. driverId: 'd1',
  478. escortId: 'e1',
  479. load: {
  480. id: 'load-1',
  481. contracts: [],
  482. products: [{ id: 'line-1', planProductId: 'product-row-1' }]
  483. },
  484. deliveries: [
  485. { id: 'stop-1', contracts: [], products: [{ id: 'line-2', planProductId: 'product-row-2' }] }
  486. ]
  487. }
  488. const h = harness({ copy: true, get: async () => structuredClone(original) })
  489. await h.handleOpened()
  490. assert.equal(h.pageReady.value, true)
  491. assert.equal(h.loadForm.value.id, undefined)
  492. assert.equal(h.loadForm.value.products[0].planProductId, undefined)
  493. assert.equal(h.deliveryForms.value[0].id, undefined)
  494. assert.equal(h.deliveryForms.value[0].products[0].id, undefined)
  495. assert.equal(h.formData.vehicleId, '')
  496. assert.equal(h.selectedDriverId.value, '')
  497. assert.equal(h.selectedEscortId.value, '')
  498. await h.handleSubmit('draft')
  499. assert.equal(h.writes[0][0], 'create')
  500. assert.equal(
  501. h.writes.some((row) => row[0] === 'update'),
  502. false
  503. )
  504. })
  505. test('draft keeps minimal fields and formal save still requires original validation', async () => {
  506. const h = harness({ newPlan: true })
  507. await h.handleOpened()
  508. h.basicFormRef.value.validate = async () => false
  509. await h.handleSubmit('save')
  510. assert.deepEqual(h.writes, [])
  511. await h.handleSubmit('draft')
  512. assert.equal(h.writes[0][0], 'create')
  513. })
  514. test('desktop edit GET error still propagates and clears the loading state', async () => {
  515. const h = harness({
  516. page: false,
  517. get: async () => {
  518. throw new Error('desktop failure')
  519. }
  520. })
  521. await assert.rejects(h.handleOpened(), /desktop failure/)
  522. assert.equal(h.detailLoading.value, false)
  523. assert.equal(h.pageLoadError.value, '')
  524. })
  525. test('empty dependency error still provides a visible retry state', async () => {
  526. const h = harness({
  527. master: async () => {
  528. throw new Error('')
  529. }
  530. })
  531. await h.handleOpened()
  532. assert.ok(h.pageLoadError.value)
  533. })
  534. test('new initialization resets vehicle loading left by an invalidated generation', async () => {
  535. const h = harness({ newPlan: true })
  536. h.vehicleLoading.value = true
  537. await h.handleOpened()
  538. assert.equal(h.vehicleLoading.value, false)
  539. })
  540. for (const kind of ['driver', 'transport'])
  541. test(`${kind} refill failure prevents saving mobile details`, async () => {
  542. const fail = async () => {
  543. throw new Error(`${kind} failed`)
  544. }
  545. const h = harness({
  546. realDependencies: true,
  547. get: async () => ({
  548. id: 'plan-1',
  549. driverId: kind === 'driver' ? 'd1' : '',
  550. transportId: kind === 'transport' ? 't1' : ''
  551. }),
  552. overrides:
  553. kind === 'driver'
  554. ? { personnelApi: { list: async () => [], get: fail } }
  555. : { transportApi: { get: fail } }
  556. })
  557. await h.handleOpened()
  558. await blocked(h)
  559. assert.match(h.pageLoadError.value, /failed/)
  560. })
  561. function deliveryWatcher(h: ReturnType<typeof harness>) {
  562. const watcher = ast.statements.find(
  563. (node) =>
  564. ts.isExpressionStatement(node) &&
  565. node.getText(ast).startsWith('watch(') &&
  566. node.getText(ast).includes('contractKey: collectStopContractIds')
  567. )
  568. assert.ok(watcher)
  569. let callback!: (...args: any[]) => Promise<void>
  570. const bindings = {
  571. mobileStopIdentity, ...h,
  572. pageLoadGeneration: 1,
  573. deliveryContractFetchSeqByIndex: {},
  574. watch: (_source: any, cb: any) => {
  575. callback = cb
  576. }
  577. }
  578. new Function(
  579. ...Object.keys(bindings),
  580. ts.transpile(watcher.getText(ast), { target: ts.ScriptTarget.ESNext })
  581. )(...Object.values(bindings))
  582. return callback
  583. }
  584. test('mobile removing a delivery contract still clears its products', async () => {
  585. const h = harness()
  586. await h.handleOpened()
  587. h.deliveryForms.value = [{ products: [{ productId: 'p1', contractId: 'c1' }], contracts: [] }]
  588. await deliveryWatcher(h)([{ contractKey: '' }], [{ contractKey: 'c1' }])
  589. assert.deepEqual(h.deliveryForms.value[0].products, [])
  590. })
  591. test('delivery contract metadata failure preserves historical products', async () => {
  592. const h = harness({
  593. contract: async () => {
  594. throw new Error('offline')
  595. }
  596. })
  597. await h.handleOpened()
  598. h.deliveryForms.value = [{ products: [{ productId: 'p1' }], contracts: [{ contractId: 'c1' }] }]
  599. await deliveryWatcher(h)([{ contractKey: 'c1' }], [{ contractKey: 'old' }])
  600. assert.deepEqual(h.deliveryForms.value[0].products, [{ productId: 'p1' }])
  601. assert.equal(h.pageReady.value, false)
  602. })
  603. function enrichmentHelper(file: string, name: string, fetchProductDetail: any) {
  604. const text = readFileSync(new URL(`../src/composables/${file}.ts`, import.meta.url), 'utf8')
  605. const source = ts.createSourceFile('helper.ts', text, ts.ScriptTarget.Latest, true)
  606. const declaration = source.statements.find(
  607. (node) => ts.isFunctionDeclaration(node) && node.name?.text === name
  608. )
  609. assert.ok(declaration)
  610. const code = ts.transpile(declaration.getText(source).replace(/^export\s+/, ''), {
  611. target: ts.ScriptTarget.ESNext
  612. })
  613. return new Function('fetchProductDetail', ...Object.keys(brand), code + `;return ${name}`)(
  614. fetchProductDetail,
  615. ...Object.values(brand)
  616. )
  617. }
  618. for (const [file, name] of [
  619. ['usePlContractToPlan', 'enrichPlanProductLinesAlias'],
  620. ['usePlToxicProductCertCheck', 'enrichPlanProductsHazardFromCrm']
  621. ]) {
  622. test(`${name} strict mode rejects failed product metadata while default preserves existing behavior`, async () => {
  623. const helper = enrichmentHelper(file, name, async () => {
  624. throw new Error('product offline')
  625. })
  626. const rows = [
  627. {
  628. productId: 'p1',
  629. productName: '历史产品',
  630. isToxic: 1,
  631. brandMode: 'required',
  632. brandId: 'b1',
  633. brandName: '品牌'
  634. }
  635. ]
  636. const before = structuredClone(rows)
  637. await assert.rejects(helper(rows, true), /product offline/)
  638. assert.deepEqual(rows, before)
  639. const fallback = await helper(structuredClone(rows))
  640. assert.equal(fallback[0].brandId, 'b1')
  641. assert.equal(fallback[0].isToxic, 1)
  642. })
  643. }
  644. for (const copy of [false, true])
  645. test(`mobile ${copy ? 'copy' : 'edit'} opts into strict product initialization`, async () => {
  646. const strictArgs: unknown[] = []
  647. const h = harness({
  648. copy,
  649. enrich: async (rows: any, strict: boolean) => {
  650. strictArgs.push(strict)
  651. return rows
  652. },
  653. overrides: {
  654. enrichPlanProductLinesAlias: async (rows: any, strict: boolean) => {
  655. strictArgs.push(strict)
  656. return rows
  657. }
  658. }
  659. })
  660. await h.handleOpened()
  661. assert.deepEqual(strictArgs, [true, true])
  662. })
  663. test('delivery slow contract completion cannot clear products selected after fast contract completion', async () => {
  664. const slow = deferred()
  665. const h = harness({
  666. contract: (id: string) => (id === 'slow' ? slow.promise : Promise.resolve({ id, products: [] }))
  667. })
  668. await h.handleOpened()
  669. const watchDelivery = deliveryWatcher(h)
  670. h.deliveryForms.value = [
  671. { contracts: [{ contractId: 'slow' }], products: [{ productId: 'original' }] }
  672. ]
  673. const first = watchDelivery([{ contractKey: 'slow' }], [{ contractKey: 'original' }])
  674. h.deliveryForms.value[0].contracts = [{ contractId: 'fast' }]
  675. await watchDelivery([{ contractKey: 'fast' }], [{ contractKey: 'slow' }])
  676. h.deliveryForms.value[0].products = [{ productId: 'new-selection' }]
  677. slow.resolve({ id: 'slow', products: [] })
  678. await first
  679. assert.deepEqual(h.deliveryForms.value[0].products, [{ productId: 'new-selection' }])
  680. })
  681. for (const mode of ['draft', 'save', 'saveAndArrange', 'rearrange']) {
  682. for (const newPlan of [false, true]) {
  683. test(`late ${newPlan ? 'create' : 'update'} ${mode} cannot emit, arrange or clear a new session busy state`, async () => {
  684. const write = deferred()
  685. let started = false
  686. const h = harness({ newPlan })
  687. await h.handleOpened()
  688. h.formData.vehicleId = 'v1'
  689. const apiMethod = newPlan ? 'createFull' : 'update'
  690. h.planApi[apiMethod] = async () => {
  691. started = true
  692. return write.promise
  693. }
  694. const saving = h.handleSubmit(mode)
  695. while (!started) await Promise.resolve()
  696. await h.handleOpened()
  697. h.submitLoading.value = true
  698. h.events.splice(0)
  699. write.resolve({ id: 'new-plan' })
  700. await saving
  701. assert.equal(h.submitLoading.value, true)
  702. assert.deepEqual(h.events, [])
  703. assert.deepEqual(h.writes, [])
  704. })
  705. }
  706. }
  707. for (const stage of ['detail', 'history', 'confirm', 'arrange']) {
  708. test(`arrange ${stage} completion after unmount cannot continue or emit`, async () => {
  709. const pending = deferred()
  710. let started = false
  711. const pause = async () => {
  712. started = true
  713. return pending.promise
  714. }
  715. const h = harness({
  716. realArrange: true,
  717. overrides:
  718. stage === 'confirm'
  719. ? { confirmBeforePlanArrange: pause }
  720. : stage === 'history'
  721. ? { loadArrangeHistoricalFlags: pause }
  722. : stage === 'arrange'
  723. ? { submitPlanArrange: pause }
  724. : {}
  725. })
  726. await h.handleOpened()
  727. h.formData.vehicleId = 'v1'
  728. if (stage === 'detail') h.planApi.get = pause
  729. else
  730. h.planApi.get = async () => ({
  731. id: 'plan-1',
  732. transportStatus: stage === 'history' ? 'arranged' : 'pending',
  733. products: []
  734. })
  735. const work = h.performArrangeAfterSave('plan-1')
  736. while (!started) await Promise.resolve()
  737. h.invalidatePageLoad()
  738. h.events.splice(0)
  739. pending.resolve(stage === 'detail' ? { id: 'plan-1', products: [] } : {})
  740. await work
  741. assert.deepEqual(h.events, [])
  742. assert.deepEqual(h.writes, [])
  743. })
  744. }
  745. for (const [file, name] of [
  746. ['usePlContractToPlan', 'enrichPlanProductLinesAlias'],
  747. ['usePlToxicProductCertCheck', 'enrichPlanProductsHazardFromCrm']
  748. ]) {
  749. for (const response of [{}, { data: null }, { id: 'other' }]) {
  750. test(`${name} strict rejects malformed 200 ${JSON.stringify(response)} and preserves snapshot`, async () => {
  751. const helper = enrichmentHelper(file, name, async () => response)
  752. const rows = [
  753. {
  754. productId: 'p1',
  755. productName: '历史产品',
  756. isToxic: 1,
  757. brandMode: 'required',
  758. brandId: 'brand-1'
  759. }
  760. ]
  761. const before = structuredClone(rows)
  762. await assert.rejects(helper(rows, true), /产品/)
  763. assert.deepEqual(rows, before)
  764. await assert.doesNotReject(helper(structuredClone(rows)))
  765. })
  766. }
  767. }
  768. for (const response of [
  769. {},
  770. null,
  771. { id: 'other', products: [] },
  772. { id: 'c1' },
  773. { id: 'c1', products: {} }
  774. ]) {
  775. test(`mobile contract malformed 200 ${JSON.stringify(response)} cannot cache an empty list`, async () => {
  776. const h = harness({ contract: async () => response })
  777. await assert.rejects(h.fetchContractProductIds('c1'), /合同/)
  778. assert.deepEqual(h.contractProductIdsCache.value, {})
  779. assert.deepEqual(h.invalidations, ['c1'])
  780. assert.equal(h.pageReady.value, false)
  781. })
  782. }
  783. for (const copy of [false, true])
  784. test(`mobile ${copy ? 'copy' : 'edit'} HTTP 404 error is actionable Chinese`, async () => {
  785. const error = Object.assign(new Error('Request failed with status code 404'), {
  786. response: { status: 404 }
  787. })
  788. const h = harness({
  789. copy,
  790. get: async () => {
  791. throw error
  792. }
  793. })
  794. await h.handleOpened()
  795. assert.equal(h.pageLoadError.value, copy ? '复制来源不存在或已删除' : '运输计划不存在或已删除')
  796. })
  797. test('mobile network error is Chinese while backend business message is preserved', async () => {
  798. for (const [error, expected] of [
  799. [new Error('Network Error'), '网络连接失败,请检查网络后重新加载'],
  800. [
  801. Object.assign(new Error('Request failed with status code 500'), {
  802. response: { data: { message: '当前租户无权查看此计划' } }
  803. }),
  804. '当前租户无权查看此计划'
  805. ]
  806. ]) {
  807. const h = harness({
  808. get: async () => {
  809. throw error
  810. }
  811. })
  812. await h.handleOpened()
  813. assert.equal(h.pageLoadError.value, expected)
  814. }
  815. })
  816. test('mobile error actions meet the 44px minimum tap target', () => {
  817. const errorStyle = descriptor.styles[0].content.split('.mp-plan-form-page')[0]
  818. assert.match(errorStyle, /\.mp-plan-load-error[\s\S]*?\.el-button[\s\S]*?min-height:\s*44px/)
  819. })
  820. test('obsolete delivery contract error does not close the ready fast selection', async () => {
  821. const slow = deferred()
  822. const h = harness({
  823. contract: (id: string) => (id === 'slow' ? slow.promise : Promise.resolve({ id, products: [] }))
  824. })
  825. await h.handleOpened()
  826. const watchDelivery = deliveryWatcher(h)
  827. h.deliveryForms.value = [{ contracts: [{ contractId: 'slow' }], products: [] }]
  828. const first = watchDelivery([{ contractKey: 'slow' }], [{ contractKey: 'original' }])
  829. h.deliveryForms.value[0].contracts = [{ contractId: 'fast' }]
  830. await watchDelivery([{ contractKey: 'fast' }], [{ contractKey: 'slow' }])
  831. h.deliveryForms.value[0].products = [{ productId: 'new-selection' }]
  832. slow.reject(new Error('old contract error'))
  833. await first
  834. assert.equal(h.pageReady.value, true)
  835. assert.equal(h.pageLoadError.value, '')
  836. assert.deepEqual(h.deliveryForms.value[0].products, [{ productId: 'new-selection' }])
  837. })
  838. test('arrange hazard completion after unmount does not continue to confirmation', async () => {
  839. const pending = deferred()
  840. let calls = 0
  841. let started = false
  842. let confirmed = false
  843. const h = harness({
  844. realArrange: true,
  845. enrich: async (rows: any) => {
  846. calls += 1
  847. if (calls === 1) return rows
  848. started = true
  849. return pending.promise
  850. },
  851. overrides: {
  852. confirmBeforePlanArrange: async () => {
  853. confirmed = true
  854. }
  855. }
  856. })
  857. await h.handleOpened()
  858. h.formData.vehicleId = 'v1'
  859. const work = h.performArrangeAfterSave('plan-1')
  860. while (!started) await Promise.resolve()
  861. h.invalidatePageLoad()
  862. pending.resolve([])
  863. await work
  864. assert.equal(confirmed, false)
  865. assert.deepEqual(h.events, [])
  866. assert.deepEqual(h.writes, [])
  867. })
  868. function loadWatcher(h: ReturnType<typeof harness>) {
  869. const watcher = ast.statements.find(
  870. (node) =>
  871. ts.isExpressionStatement(node) &&
  872. node.getText(ast).startsWith('watch(') &&
  873. node.getText(ast).includes('() => loadForm.value.contracts')
  874. )
  875. assert.ok(watcher)
  876. let callback!: (...args: any[]) => Promise<void>
  877. const bindings = {
  878. mobileStopIdentity, ...h,
  879. pageLoadGeneration: 1,
  880. loadContractFetchSeq: 0,
  881. filterProductsByAllowedProductIds,
  882. syncFromLoadProducts: () => {},
  883. watch: (_source: any, cb: any) => {
  884. callback = cb
  885. }
  886. }
  887. new Function(
  888. ...Object.keys(bindings),
  889. ts.transpile(watcher.getText(ast), { target: ts.ScriptTarget.ESNext })
  890. )(...Object.values(bindings))
  891. return callback
  892. }
  893. for (const rejects of [false, true])
  894. test(`load contract old ${rejects ? 'error' : 'success'} cannot affect current selection`, async () => {
  895. const slow = deferred()
  896. const h = harness({
  897. contract: (id: string) =>
  898. id === 'slow'
  899. ? slow.promise
  900. : Promise.resolve({ id, products: [{ productId: 'fast-product' }] })
  901. })
  902. await h.handleOpened()
  903. const watchLoad = loadWatcher(h)
  904. h.loadForm.value = { contracts: [{ contractId: 'slow' }], products: [] }
  905. const first = watchLoad([{ contractId: 'slow' }], [{ contractId: 'original' }])
  906. h.loadForm.value.contracts = [{ contractId: 'fast' }]
  907. await watchLoad([{ contractId: 'fast' }], [{ contractId: 'slow' }])
  908. h.loadForm.value.products = [{ productId: 'fast-product' }]
  909. if (rejects) slow.reject(new Error('obsolete'))
  910. else slow.resolve({ id: 'slow', products: [{ productId: 'slow-product' }] })
  911. await first
  912. assert.deepEqual(h.loadForm.value.products, [{ productId: 'fast-product' }])
  913. assert.deepEqual(h.loadContractProductIds.value, ['fast-product'])
  914. assert.equal(h.pageReady.value, true)
  915. assert.equal(h.pageLoadError.value, '')
  916. })
  917. for (const mode of ['draft', 'save', 'saveAndArrange', 'rearrange'])
  918. test(`desktop ${mode} keeps its save and arrange completion`, async () => {
  919. const h = harness({ page: false, realArrange: true })
  920. await h.handleOpened()
  921. h.formData.vehicleId = 'v1'
  922. h.invalidatePageLoad()
  923. await h.handleSubmit(mode)
  924. assert.equal(h.writes[0][0], 'update')
  925. assert.equal(
  926. h.writes.some((row) => row[0] === 'arrange'),
  927. mode === 'saveAndArrange' || mode === 'rearrange'
  928. )
  929. assert.equal(h.events.filter((event) => event[0] === 'success').length, 1)
  930. assert.equal(h.submitLoading.value, false)
  931. })
  932. // Execute the production watcher through Vue's scheduler, together with the real
  933. // fetchContractProductIds closure from harness(). Only the contract API is stubbed.
  934. function mountDeliveryWatcher(h: ReturnType<typeof harness>) {
  935. const watcher = ast.statements.find(
  936. (node) =>
  937. ts.isExpressionStatement(node) &&
  938. node.getText(ast).startsWith('watch(') &&
  939. node.getText(ast).includes('contractKey: collectStopContractIds')
  940. )
  941. assert.ok(watcher)
  942. const bindings = { ...h, mobileStopIdentity, watch, pageLoadGeneration: 1, deliveryContractFetchSeqByIndex: {} }
  943. const scope = effectScope()
  944. scope.run(() =>
  945. new Function(
  946. ...Object.keys(bindings),
  947. ts.transpile(watcher.getText(ast), { target: ts.ScriptTarget.ESNext })
  948. )(...Object.values(bindings))
  949. )
  950. return () => scope.stop()
  951. }
  952. async function flushDeliveryWatchers() {
  953. for (let tick = 0; tick < 30; tick += 1) await nextTick()
  954. }
  955. for (const page of [true, false])
  956. test(`real Vue delivery watcher ${page ? 'mobile' : 'desktop'} clears both changed segments despite crossed requests`, async () => {
  957. const slow = deferred()
  958. const h = harness({
  959. page,
  960. contract: async (id: string) => {
  961. if (id === 'new-0') await slow.promise
  962. return { id, products: [] }
  963. }
  964. })
  965. await h.handleOpened()
  966. h.deliveryForms.value = [
  967. { contracts: [{ contractId: 'old-0' }], products: [{ productId: 'old-product-0', contractId: 'old-0' }] },
  968. { contracts: [{ contractId: 'old-1' }], products: [{ productId: 'old-product-1', contractId: 'old-1' }] }
  969. ]
  970. const stop = mountDeliveryWatcher(h)
  971. try {
  972. h.deliveryForms.value[0].contracts = [{ contractId: 'new-0' }]
  973. await nextTick()
  974. h.deliveryForms.value[1].contracts = [{ contractId: 'new-1' }]
  975. await nextTick()
  976. slow.resolve({})
  977. await flushDeliveryWatchers()
  978. assert.deepEqual(
  979. h.deliveryForms.value.map((row: any) => row.products),
  980. [[], []]
  981. )
  982. assert.equal(h.pageLoadError.value, '')
  983. } finally {
  984. stop()
  985. }
  986. })
  987. test('real Vue watcher does not let an earlier batched callback supersede a later segment selection', async () => {
  988. const slow = deferred()
  989. const fast = deferred()
  990. const requests: string[] = []
  991. const h = harness({
  992. contract: async (id: string) => {
  993. requests.push(id)
  994. if (id === 'new-0') await slow.promise
  995. if (id === 'latest-1') await fast.promise
  996. return { id, products: [] }
  997. }
  998. })
  999. await h.handleOpened()
  1000. h.deliveryForms.value = [
  1001. { contracts: [{ contractId: 'old-0' }], products: [{ productId: 'old-product-0', contractId: 'old-0' }] },
  1002. { contracts: [{ contractId: 'old-1' }], products: [{ productId: 'old-product-1', contractId: 'old-1' }] }
  1003. ]
  1004. const stop = mountDeliveryWatcher(h)
  1005. try {
  1006. h.deliveryForms.value[0].contracts = [{ contractId: 'new-0' }]
  1007. h.deliveryForms.value[1].contracts = [{ contractId: 'intermediate-1' }]
  1008. await nextTick()
  1009. h.deliveryForms.value[1].contracts = [{ contractId: 'latest-1' }]
  1010. await nextTick()
  1011. slow.resolve({})
  1012. await flushDeliveryWatchers()
  1013. fast.resolve({})
  1014. await flushDeliveryWatchers()
  1015. assert.deepEqual(
  1016. h.deliveryForms.value.map((row: any) => row.products),
  1017. [[], []]
  1018. )
  1019. assert.equal(requests.includes('intermediate-1'), false)
  1020. h.deliveryForms.value[1].products = [{ productId: 'latest-product-1' }]
  1021. await flushDeliveryWatchers()
  1022. assert.deepEqual(h.deliveryForms.value[1].products, [{ productId: 'latest-product-1' }])
  1023. } finally {
  1024. stop()
  1025. }
  1026. })
  1027. test('real Vue watcher preserves newly selected products while another segment finishes later', async () => {
  1028. const slow = deferred()
  1029. const h = harness({
  1030. contract: async (id: string) => {
  1031. if (id === 'new-0') await slow.promise
  1032. return { id, products: [] }
  1033. }
  1034. })
  1035. await h.handleOpened()
  1036. h.deliveryForms.value = [
  1037. { contracts: [{ contractId: 'old-0' }], products: [{ productId: 'old-product-0', contractId: 'old-0' }] },
  1038. { contracts: [{ contractId: 'old-1' }], products: [{ productId: 'old-product-1', contractId: 'old-1' }] }
  1039. ]
  1040. const stop = mountDeliveryWatcher(h)
  1041. try {
  1042. h.deliveryForms.value[0].contracts = [{ contractId: 'new-0' }]
  1043. await nextTick()
  1044. h.deliveryForms.value[1].contracts = [{ contractId: 'new-1' }]
  1045. await flushDeliveryWatchers()
  1046. h.deliveryForms.value[1].products = [{ productId: 'new-selection' }]
  1047. slow.resolve({})
  1048. await flushDeliveryWatchers()
  1049. assert.deepEqual(h.deliveryForms.value[0].products, [])
  1050. assert.deepEqual(h.deliveryForms.value[1].products, [{ productId: 'new-selection' }])
  1051. } finally {
  1052. stop()
  1053. }
  1054. })
  1055. for (const rejects of [false, true])
  1056. test(`real Vue batched delivery changes still process later segments after obsolete first ${rejects ? 'error' : 'success'}`, async () => {
  1057. const slow = deferred()
  1058. const requests: string[] = []
  1059. const h = harness({
  1060. contract: async (id: string) => {
  1061. requests.push(id)
  1062. if (id === 'slow-0') await slow.promise
  1063. return { id, products: [] }
  1064. }
  1065. })
  1066. await h.handleOpened()
  1067. h.deliveryForms.value = [
  1068. { contracts: [{ contractId: 'old-0' }], products: [{ productId: 'old-product-0', contractId: 'old-0' }] },
  1069. { contracts: [{ contractId: 'old-1' }], products: [{ productId: 'old-product-1', contractId: 'old-1' }] }
  1070. ]
  1071. const stop = mountDeliveryWatcher(h)
  1072. try {
  1073. h.deliveryForms.value[0].contracts = [{ contractId: 'slow-0' }]
  1074. h.deliveryForms.value[1].contracts = [{ contractId: 'new-1' }]
  1075. await nextTick()
  1076. h.deliveryForms.value[0].contracts = [{ contractId: 'latest-0' }]
  1077. await flushDeliveryWatchers()
  1078. if (rejects) slow.reject(new Error('obsolete first segment'))
  1079. else slow.resolve({})
  1080. await flushDeliveryWatchers()
  1081. assert.ok(
  1082. requests.includes('new-1'),
  1083. 'the batched later segment must still request its contract'
  1084. )
  1085. assert.deepEqual(
  1086. h.deliveryForms.value.map((row: any) => row.products),
  1087. [[], []]
  1088. )
  1089. assert.equal(h.pageReady.value, true)
  1090. assert.equal(h.pageLoadError.value, '')
  1091. } finally {
  1092. stop()
  1093. }
  1094. })
  1095. test('mobile delivery watcher preserves segment products and notes during reorder and clears only changed contract target', async () => {
  1096. const slow=deferred()
  1097. const h=harness({contract:async(id:string)=>{if(id==='new-a')await slow.promise;return {id,products:[]}}})
  1098. await h.handleOpened()
  1099. h.deliveryForms.value=[
  1100. {customerId:'same',contracts:[{contractId:'old-a'}],products:[{productId:'a',contractId:'old-a'}],notes:'A'},
  1101. {customerId:'same',contracts:[{contractId:'old-b'}],products:[{productId:'b',contractId:'old-b'}],notes:'B'}
  1102. ]
  1103. const stop=mountDeliveryWatcher(h)
  1104. try {
  1105. h.deliveryForms.value[0].contracts=[{contractId:'new-a'}]
  1106. await nextTick()
  1107. h.deliveryForms.value=[{...h.deliveryForms.value[1],seq:1},{...h.deliveryForms.value[0],seq:2}]
  1108. await flushDeliveryWatchers()
  1109. assert.deepEqual(h.deliveryForms.value.map((r:any)=>r.products),[[{productId:'b',contractId:'old-b'}],[{productId:'a',contractId:'old-a'}]])
  1110. slow.resolve({}); await flushDeliveryWatchers()
  1111. assert.deepEqual(h.deliveryForms.value.map((r:any)=>r.products),[[{productId:'b',contractId:'old-b'}],[]])
  1112. assert.deepEqual(h.deliveryForms.value.map((r:any)=>r.notes),['B','A'])
  1113. } finally {stop()}
  1114. })
  1115. test('mobile removed delivery response cannot clear surviving same-customer segment', async () => {
  1116. const slow=deferred(), h=harness({contract:async(id:string)=>{if(id==='new-a')await slow.promise;return {id,products:[]}}})
  1117. await h.handleOpened()
  1118. h.deliveryForms.value=[{customerId:'same',contracts:[{contractId:'old-a'}],products:[{productId:'a'}]},{customerId:'same',contracts:[{contractId:'old-b'}],products:[{productId:'b'}]}]
  1119. const stop=mountDeliveryWatcher(h)
  1120. try {
  1121. h.deliveryForms.value[0].contracts=[{contractId:'new-a'}];await nextTick()
  1122. h.deliveryForms.value=[{...h.deliveryForms.value[1],seq:1}];await flushDeliveryWatchers()
  1123. slow.resolve({});await flushDeliveryWatchers()
  1124. assert.deepEqual(h.deliveryForms.value[0].products,[{productId:'b'}])
  1125. } finally {stop()}
  1126. })
  1127. for(const removed of [false,true]) test(`mobile load contract ${removed?'removal':'replacement with same product'} clears only its products`,async()=>{
  1128. const h=harness({contract:async(id:string)=>({id,products:[{productId:'p'}]})})
  1129. await h.handleOpened()
  1130. h.loadForm.value={contracts:removed?[]:[{contractId:'new'}],products:[{productId:'p',contractProductId:'old-line',contractId:'old'}]}
  1131. const run=loadWatcher(h)
  1132. await run(h.loadForm.value.contracts,[{contractId:'old'}])
  1133. assert.deepEqual(h.loadForm.value.products,[])
  1134. })
  1135. for (const newPlan of [false, true]) test(`old server without capability blocks ${newPlan ? 'new' : 'edit'} page readiness and all writes`, async () => {
  1136. const h = harness({ newPlan, overrides: { assertMobilePersistenceCapability, planApi: { mobileProductCapability: async () => ({}) } } })
  1137. await h.handleOpened()
  1138. assert.equal(h.pageReady.value, false)
  1139. assert.match(h.pageLoadError.value, /服务.*更新/)
  1140. await h.handleSubmit('draft')
  1141. assert.deepEqual(h.writes, [])
  1142. })
  1143. test('successful capability handshake still rejects an old detail response without the explicit marker', async () => {
  1144. const h = harness({ overrides: { assertMobilePersistenceCapability } })
  1145. await h.handleOpened()
  1146. assert.equal(h.pageReady.value, false)
  1147. assert.match(h.pageLoadError.value, /服务.*更新/)
  1148. })
  1149. test('actual copy mapper preserves same-packaging second-load and shared delivery association while clearing source ids', async () => {
  1150. const { mobileLoadProductKey } = await import('../src/views/m/pl/plan/mobilePlanPersistence')
  1151. const product = (id: string, planProductId: string) => ({ id, planProductId, productId: 'p', productNo: 'P', productName: '标准名', packageSpec: '20kg/袋', model: 'M', unit: '吨' })
  1152. const sourcePlan = { id: 'plan-1', load: { products: [product('l1', 'pp1'), product('l2', 'pp2')], contracts: [] }, deliveries: [{ id: 'd1', products: [product('dl1', 'pp2')], contracts: [] }, { id: 'd2', products: [product('dl2', 'pp2')], contracts: [] }], products: [] }
  1153. const h = harness({ copy: true, get: async () => sourcePlan })
  1154. await h.handleOpened()
  1155. assert.equal(h.pageReady.value, true)
  1156. const key = mobileLoadProductKey(h.loadForm.value.products[1])
  1157. assert.deepEqual(h.deliveryForms.value.map((row: any) => row.products[0].mobileLoadProductKey), [key, key])
  1158. assert.ok(h.loadForm.value.products.every((row: any) => !row.id && !row.planProductId))
  1159. assert.ok(h.deliveryForms.value.every((row: any) => !row.id && !row.products[0].id && !row.products[0].planProductId))
  1160. })
  1161. test('capability endpoint 404 explains service upgrade rather than a missing plan', async () => {
  1162. const h = harness({newPlan: true, overrides: {planApi: {mobileProductCapability: async () => {throw Object.assign(new Error('Request failed with status code 404'), {response:{status:404}})}}}})
  1163. await h.handleOpened()
  1164. assert.equal(h.pageReady.value,false)
  1165. assert.match(h.pageLoadError.value,/服务.*更新/)
  1166. assert.doesNotMatch(h.pageLoadError.value,/计划不存在/)
  1167. })