mobile-pl-record-identity.test.ts 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050
  1. import assert from 'node:assert/strict'
  2. import test from 'node:test'
  3. import { readFileSync } from 'node:fs'
  4. import { parse } from '@vue/compiler-sfc'
  5. import ts from 'typescript'
  6. import * as vue from 'vue'
  7. function deferred() {
  8. let resolve!: (value?: any) => void
  9. let reject!: (error: any) => void
  10. const promise = new Promise<any>((a, b) => {
  11. resolve = a
  12. reject = b
  13. })
  14. return { promise, resolve, reject }
  15. }
  16. const flush = async () => {
  17. for (let i = 0; i < 20; i++) await Promise.resolve()
  18. }
  19. function harness(page: string, overrides: Record<string, any> = {}, initialProps: any = {}) {
  20. const content = parse(
  21. readFileSync(new URL(`../src/views/m/pl/${page}.vue`, import.meta.url), 'utf8')
  22. ).descriptor.scriptSetup!.content
  23. const ast = ts.createSourceFile('page.ts', content, ts.ScriptTarget.Latest, true)
  24. const route = vue.reactive({
  25. params: { id: 'A' },
  26. query: {} as Record<string, string>,
  27. fullPath: '/A',
  28. path: '/A'
  29. })
  30. const props = vue.reactive({ modelValue: true, planId: 'A', permitted: true, ...initialProps })
  31. const messages: any[] = []
  32. const unmounts: Array<() => void> = []
  33. const writes: any[] = [],
  34. pushes: any[] = [],
  35. calls: string[] = []
  36. const bindings: Record<string, any> = Object.fromEntries(
  37. Object.entries(vue).filter(
  38. ([key]) => /^[a-zA-Z_$][\w$]*$/.test(key) && key !== 'default' && key !== 'version'
  39. )
  40. )
  41. for (const s of ast.statements) {
  42. if (!ts.isImportDeclaration(s)) continue
  43. const clause = s.importClause
  44. if (clause?.name) bindings[clause.name.text] = () => undefined
  45. if (clause?.namedBindings && ts.isNamedImports(clause.namedBindings))
  46. for (const item of clause.namedBindings.elements) bindings[item.name.text] = () => undefined
  47. }
  48. const read = async (arg: any) => {
  49. const id = typeof arg === 'string' ? arg : arg.id
  50. calls.push(id)
  51. return {
  52. data: {
  53. id,
  54. status: 'draft',
  55. products: [{ id: 'p', productId: 'p', isToxic: 1 }],
  56. productList: []
  57. }
  58. }
  59. }
  60. const planApi = {
  61. get: read,
  62. getArrangeInfo: async () => ({ data: {} }),
  63. waitNotice: async (id: string) => {
  64. writes.push(id)
  65. return { data: { id, products: [] } }
  66. },
  67. cancelWaitNotice: async (id: string) => {
  68. writes.push(id)
  69. return { data: { id, products: [] } }
  70. },
  71. cancel: async (...args: any[]) => {
  72. writes.push(args)
  73. return { data: { id: args[0], products: [] } }
  74. },
  75. setHazardCertReady: async (...args: any[]) => writes.push(args),
  76. updateProductCerts: async (...args: any[]) => writes.push(args),
  77. ...overrides.planApi
  78. }
  79. Object.assign(bindings, {
  80. useRoute: () => route,
  81. useRouter: () => ({
  82. push: (v: any) => pushes.push(v),
  83. replace: async (v: any) => {
  84. if (typeof v === 'object') route.query = v.query
  85. else pushes.push(v)
  86. }
  87. }),
  88. useUserStore: () => ({ accessToken: 'session', info: { id: 'user' } }),
  89. useDictStore: () => ({ getLabel: () => '', getTagType: () => '' }),
  90. onMounted() {},
  91. onUnmounted: (fn: any) => unmounts.push(fn),
  92. onBeforeUnmount: (fn: any) => unmounts.push(fn),
  93. loadMobileDetailReads: async () => ({ errors: [] }),
  94. resolvePlanStopsForList: (plan: any) => ({
  95. load: plan.load || {},
  96. deliveries: plan.deliveries || []
  97. }),
  98. mapPlanProductToFormRow: (product: any) => product,
  99. mapLoadFromApi: (load: any) => load,
  100. mapDeliveriesFromApi: (deliveries: any[]) => deliveries,
  101. resolveStopProductsForView: (products: any[]) => products || [],
  102. resolveLoadViewAddress: (_plan: any, load: any) => load.address || '',
  103. resolveDeliveryViewAddress: (_plan: any, stop: any) => stop.address || '',
  104. getContract: read,
  105. getQuote: read,
  106. fetchProductDetail: read,
  107. listContractApproval: async () => ({ data: [] }),
  108. ElMessage: Object.fromEntries(
  109. ['warning', 'error', 'success', 'info'].map((k) => [
  110. k,
  111. (...args: any[]) => messages.push([k, ...args])
  112. ])
  113. ),
  114. fetchGetMenuCodes: async () => ({ all: true }),
  115. canEditPlan: () => true,
  116. canArrangeTransport: () => true,
  117. canWaitNotice: () => true,
  118. canCancelWaitNotice: () => true,
  119. canVoidPlan: () => true,
  120. canEdit: (p: any) => !!p,
  121. canSave: (a: boolean, u: boolean, s: boolean, n: number) => a && !u && !s && n > 0,
  122. certPayload: (p: any) => JSON.parse(JSON.stringify(p)),
  123. isHazardProduct: (p: any) => !!p.isToxic,
  124. enrichPlanProductsHazardFromCrm: async (p: any) => p,
  125. isHazardCertReady: () => false,
  126. planHasHazardProducts: (p: any) => !!p?.length,
  127. buildPlanCard: () => ({}),
  128. splitMpPlanDetailActions: () => ({ primary: [], more: [] }),
  129. usePlPlanArrangeActions: (options: any) => arrangeHarness(planApi, options),
  130. showCrmConfirmDialog: async () => {},
  131. defineExpose() {},
  132. defineProps: () => props,
  133. defineEmits:
  134. () =>
  135. (...v: any[]) =>
  136. pushes.push(v),
  137. document: { querySelector: () => null },
  138. window: { removeEventListener() {}, scrollTo() {} },
  139. ...overrides,
  140. planApi
  141. })
  142. const statements = ast.statements.filter((s) => !ts.isImportDeclaration(s))
  143. const names = statements.flatMap((s) =>
  144. ts.isFunctionDeclaration(s)
  145. ? [s.name!.text]
  146. : ts.isVariableStatement(s)
  147. ? s.declarationList.declarations.flatMap((d) =>
  148. ts.isObjectBindingPattern(d.name)
  149. ? d.name.elements.map((e) => e.name.getText(ast))
  150. : [d.name.getText(ast)]
  151. )
  152. : []
  153. )
  154. const code = ts.transpile(statements.map((s) => s.getText(ast)).join('\n'), {
  155. target: ts.ScriptTarget.ESNext
  156. })
  157. const scope = vue.effectScope()
  158. const api: any = scope.run(() =>
  159. new Function(...Object.keys(bindings), `${code};return {${names.join(',')}}`)(
  160. ...Object.values(bindings)
  161. )
  162. )
  163. return {
  164. ...api,
  165. props: bindings.defineProps(),
  166. messages,
  167. planApi,
  168. route,
  169. calls,
  170. writes,
  171. pushes,
  172. ready: api.onShellReady || api.onReady,
  173. unmount: () => {
  174. unmounts.forEach((fn) => fn())
  175. scope.stop()
  176. }
  177. }
  178. }
  179. function arrangeHarness(planApi: any, options: any = undefined, overrides: any = {}) {
  180. const text = readFileSync(
  181. new URL('../src/composables/usePlPlanArrangeActions.ts', import.meta.url),
  182. 'utf8'
  183. )
  184. const ast = ts.createSourceFile('arrange.ts', text, ts.ScriptTarget.Latest, true)
  185. const code = ts.transpile(
  186. ast.statements
  187. .filter((s) => !ts.isImportDeclaration(s))
  188. .map((s) => s.getText(ast))
  189. .join('\n')
  190. .replace(/export /g, ''),
  191. { target: ts.ScriptTarget.ESNext }
  192. )
  193. const env = {
  194. ...Object.fromEntries(
  195. Object.entries(vue).filter(([k]) => /^[a-zA-Z_$][\w$]*$/.test(k) && k !== 'version')
  196. ),
  197. planApi,
  198. pickBxData: (r: any) => r.data ?? r,
  199. useUserStore: () => ({ accessToken: 'session', info: { id: 'user' } }),
  200. useDictStore: () => ({ getLabel: () => '' }),
  201. ElMessage: { success() {}, warning() {}, error() {} },
  202. ...overrides
  203. }
  204. delete (env as any).default
  205. return new Function(...Object.keys(env), `${code}; return usePlPlanArrangeActions`)(
  206. ...Object.values(env)
  207. )(options)
  208. }
  209. test('transport: ready gate, idempotence and sync reset', async () => {
  210. const h = harness('plan/detail')
  211. h.route.params.id = 'B'
  212. await flush()
  213. assert.deepEqual(h.calls, [])
  214. h.onShellReady()
  215. await flush()
  216. h.onShellReady()
  217. await flush()
  218. assert.deepEqual(h.calls, ['B'])
  219. h.moreOpen.value = h.voidOpen.value = h.vehicleOpen.value = true
  220. h.route.params.id = 'C'
  221. assert.equal(h.plan.value, null)
  222. assert.equal(h.moreOpen.value, false)
  223. assert.equal(h.voidOpen.value, false)
  224. assert.equal(h.vehicleOpen.value, false)
  225. await flush()
  226. assert.deepEqual(h.calls, ['B', 'C'])
  227. h.route.query.tab = 'info'
  228. await flush()
  229. assert.deepEqual(h.calls, ['B', 'C'])
  230. h.unmount()
  231. })
  232. for (const result of [null, {}, { id: 'WRONG' }])
  233. test(`transport invalid success ${JSON.stringify(result)}`, async () => {
  234. const h = harness('plan/detail', { planApi: { get: async () => ({ data: result }) } })
  235. h.onShellReady()
  236. await flush()
  237. assert.equal(h.plan.value, null)
  238. assert.ok(h.errorText.value)
  239. h.unmount()
  240. })
  241. for (const kind of ['wait', 'cancel-wait', 'void'])
  242. test(`transport ${kind} delayed confirmation`, async () => {
  243. const confirm = deferred()
  244. const h = harness('plan/detail', { showCrmConfirmDialog: () => confirm.promise })
  245. h.onShellReady()
  246. await flush()
  247. const pending = kind === 'void' ? h.confirmVoidPlan() : h.runAction(kind)
  248. confirm.resolve()
  249. h.route.params.id = 'B'
  250. await pending
  251. await flush()
  252. assert.deepEqual(h.writes, [])
  253. h.unmount()
  254. })
  255. test('arrange opt-in old internal info cannot publish after reset', async () => {
  256. const old = deferred()
  257. const a = arrangeHarness({ getArrangeInfo: () => old.promise }, { isolateRecord: true })
  258. const pending = a.loadArrangeActions('A', { id: 'A', transportStatus: 'arranged' })
  259. a.resetArrangeActions()
  260. old.resolve({ data: { driverUrl: 'OLD', siteRecords: [{ siteUrl: 'OLD' }] } })
  261. await pending
  262. assert.equal(a.driverUrl.value, '')
  263. assert.deepEqual(a.siteRecords.value, [])
  264. })
  265. test('cert parent delayed confirmation invalidated synchronously', async () => {
  266. const confirm = deferred()
  267. const h = harness('plan-cert/detail', { showCrmConfirmDialog: () => confirm.promise })
  268. await h.init()
  269. const pending = h.setReady({ id: 'A' }, true)
  270. await flush()
  271. confirm.resolve()
  272. h.route.params.id = 'B'
  273. await pending
  274. assert.deepEqual(h.writes, [])
  275. h.unmount()
  276. })
  277. test('cert upload old successful save cannot close or notify new record', async () => {
  278. const saved = deferred()
  279. const h = harness('plan-cert/CertUpload', {
  280. planApi: { updateProductCerts: () => saved.promise }
  281. })
  282. await flush()
  283. const pending = h.save()
  284. h.props.planId = 'B'
  285. await flush()
  286. saved.resolve()
  287. await pending
  288. assert.deepEqual(h.pushes, [])
  289. assert.deepEqual(h.messages, [])
  290. h.unmount()
  291. })
  292. function detailLoader(read: (id: string) => Promise<any>, useInitialPlan = false) {
  293. return () => {
  294. const state: any = Object.fromEntries(
  295. [
  296. 'plan',
  297. 'products',
  298. 'transport',
  299. 'freightSettlement',
  300. 'vehicleBundle',
  301. 'dibangRows',
  302. 'ioOrder'
  303. ].map((k) => [k, vue.ref(k === 'products' || k === 'dibangRows' ? [] : null)])
  304. )
  305. state.loading = vue.ref(false)
  306. state.resetDetail = () => {
  307. state.plan.value = null
  308. }
  309. state.loadDetail = async (id: string, options?: any) => {
  310. state.loading.value = true
  311. try {
  312. state.plan.value =
  313. useInitialPlan && options?.initialPlan ? options.initialPlan : await read(id)
  314. } finally {
  315. state.loading.value = false
  316. }
  317. }
  318. return state
  319. }
  320. }
  321. for (const reject of [false, true])
  322. test(`cert detail unmount guards old ${reject ? 'error' : 'success'} and contract`, async () => {
  323. const slow = deferred(),
  324. contract = deferred()
  325. const h = harness('plan-cert/CertDetail', {
  326. usePlPlanViewDetail: detailLoader(() => slow.promise),
  327. getContract: () => contract.promise
  328. })
  329. await flush()
  330. const pending = h.openContract('C')
  331. h.unmount()
  332. if (reject) slow.reject(new Error('old'))
  333. else slow.resolve({ id: 'A' })
  334. contract.resolve({ data: { id: 'C' } })
  335. await pending
  336. await flush()
  337. assert.equal(h.contractOpen.value, false)
  338. assert.equal(h.error.value, '')
  339. assert.equal(h.plan.value, null)
  340. })
  341. for (const reject of [false, true])
  342. for (const transition of ['B', 'ABA', 'retry', 'unmount'])
  343. test(`transport ${transition}: old ${reject ? 'error/finally' : 'success'} cannot publish`, async () => {
  344. const old = deferred(),
  345. fresh = deferred()
  346. let n = 0
  347. const h = harness('plan/detail', {
  348. planApi: { get: () => (++n === 1 ? old.promise : fresh.promise) }
  349. })
  350. h.onShellReady()
  351. await flush()
  352. if (transition === 'unmount') h.unmount()
  353. else if (transition === 'retry') void h.loadDetail()
  354. else {
  355. h.route.params.id = 'B'
  356. if (transition === 'ABA') h.route.params.id = 'A'
  357. }
  358. if (reject) old.reject(new Error('old'))
  359. else old.resolve({ data: { id: 'A' } })
  360. await flush()
  361. assert.equal(h.plan.value, null)
  362. assert.equal(h.errorText.value, '')
  363. if (transition !== 'unmount') {
  364. assert.equal(h.loading.value, true)
  365. fresh.resolve({ data: { id: h.route.params.id } })
  366. await flush()
  367. assert.equal(h.plan.value.id, h.route.params.id)
  368. assert.equal(h.loading.value, false)
  369. h.unmount()
  370. }
  371. })
  372. for (const kind of ['wait', 'cancel-wait', 'void'])
  373. for (const reject of [false, true])
  374. test(`transport ${kind}: old issued write ${reject ? 'rejects' : 'resolves'} after switching`, async () => {
  375. const old = deferred()
  376. const apiName =
  377. kind === 'wait' ? 'waitNotice' : kind === 'void' ? 'cancel' : 'cancelWaitNotice'
  378. const h = harness('plan/detail', { planApi: { [apiName]: () => old.promise } })
  379. h.onShellReady()
  380. await flush()
  381. const pending = kind === 'void' ? h.confirmVoidPlan() : h.runAction(kind)
  382. await flush()
  383. h.route.params.id = 'B'
  384. await flush()
  385. h.acting.value = true
  386. h.voidOpen.value = true
  387. if (reject) old.reject(new Error('old'))
  388. else old.resolve({ data: { id: 'A' } })
  389. await pending
  390. assert.equal(h.plan.value.id, 'B')
  391. assert.equal(h.acting.value, true)
  392. assert.equal(h.voidOpen.value, true)
  393. assert.deepEqual(h.messages, [])
  394. assert.deepEqual(h.calls, ['A', 'B'])
  395. h.unmount()
  396. })
  397. for (const kind of ['wait', 'cancel-wait', 'void'])
  398. test(`transport ${kind}: cancelled confirmation is handled`, async () => {
  399. const h = harness('plan/detail', {
  400. showCrmConfirmDialog: async () => {
  401. throw new Error('cancel')
  402. }
  403. })
  404. h.onShellReady()
  405. await flush()
  406. await (kind === 'void' ? h.confirmVoidPlan() : h.runAction(kind))
  407. assert.deepEqual(h.writes, [])
  408. assert.equal(h.acting.value, false)
  409. assert.deepEqual(h.messages, [])
  410. h.unmount()
  411. })
  412. test('transport reason is captured before confirmation; unrelated query does not cancel', async () => {
  413. const confirm = deferred(),
  414. h = harness('plan/detail', { showCrmConfirmDialog: () => confirm.promise })
  415. h.onShellReady()
  416. await flush()
  417. h.voidReason.value = 'original'
  418. const pending = h.confirmVoidPlan()
  419. h.voidReason.value = 'changed'
  420. h.route.query.tab = 'x'
  421. confirm.resolve()
  422. await pending
  423. assert.deepEqual(h.writes, [['A', { cancelReason: 'original' }]])
  424. h.unmount()
  425. })
  426. test('transport entry rejects missing loading busy and denied actions', async () => {
  427. const h = harness('plan/detail', {
  428. canWaitNotice: () => false,
  429. canCancelWaitNotice: () => false,
  430. canVoidPlan: () => false
  431. })
  432. await h.runAction('wait')
  433. h.onShellReady()
  434. await flush()
  435. for (const kind of ['wait', 'cancel-wait', 'void']) await h.runAction(kind)
  436. await h.confirmVoidPlan()
  437. assert.deepEqual(h.writes, [])
  438. h.unmount()
  439. })
  440. test('arrange opt-in ABA cancels old get and reuses existing plan', async () => {
  441. const old = deferred()
  442. let reads = 0
  443. const a = arrangeHarness(
  444. {
  445. get: () => {
  446. reads++
  447. return old.promise
  448. },
  449. getArrangeInfo: async (id: string) => ({ data: { driverUrl: id } })
  450. },
  451. { isolateRecord: true }
  452. )
  453. const pending = a.loadArrangeActions('A')
  454. await a.loadArrangeActions('B', { id: 'B', transportStatus: 'arranged' })
  455. await a.loadArrangeActions('A', { id: 'A', transportStatus: 'arranged' })
  456. old.resolve({ data: { id: 'A', transportStatus: 'pending' } })
  457. await pending
  458. assert.equal(a.driverUrl.value, 'A')
  459. assert.equal(a.plan.value.transportStatus, 'arranged')
  460. assert.equal(reads, 1)
  461. })
  462. test('arrange default PC retains legacy publication and lookup behavior', async () => {
  463. const old = deferred()
  464. let reads = 0
  465. const a = arrangeHarness({
  466. get: async () => {
  467. reads++
  468. return { data: { id: 'A', transportStatus: 'arranged' } }
  469. },
  470. getArrangeInfo: () => old.promise
  471. })
  472. const pending = a.loadArrangeActions('A')
  473. await flush()
  474. a.resetArrangeActions()
  475. old.resolve({ data: { driverUrl: 'legacy' } })
  476. await pending
  477. assert.equal(a.driverUrl.value, 'legacy')
  478. assert.equal(reads, 1)
  479. })
  480. test('arrange QR nextTick cannot copy newly bound dialog after reset', async () => {
  481. let copies = 0
  482. const a = arrangeHarness({}, { isolateRecord: true })
  483. const pending = a.copyPoundSlipQrcodeForRecord({ dbInventoryId: 'A' })
  484. a.resetArrangeActions()
  485. a.qrcodeDialogRef.value = {
  486. copyQrcodeImage: () => {
  487. copies++
  488. }
  489. }
  490. await pending
  491. assert.equal(copies, 0)
  492. assert.equal(a.qrcodeVisible.value, false)
  493. })
  494. for (const stage of ['get', 'confirm', 'write'])
  495. for (const reject of [false, true])
  496. test(`cert ready ${stage}: switched/ABA late ${reject ? 'error' : 'success'} ignored`, async () => {
  497. const old = deferred(),
  498. writes: any[] = []
  499. const h = harness('plan-cert/detail', {
  500. showCrmConfirmDialog: stage === 'confirm' ? () => old.promise : async () => {},
  501. planApi: {
  502. ...(stage === 'get' ? { get: () => old.promise } : {}),
  503. setHazardCertReady: (...args: any[]) => {
  504. writes.push(args)
  505. return stage === 'write' ? old.promise : Promise.resolve()
  506. }
  507. }
  508. })
  509. await h.init()
  510. const pending = h.setReady({ id: 'A' }, true)
  511. await flush()
  512. h.route.params.id = 'B'
  513. h.route.params.id = 'A'
  514. h.acting.value = true
  515. if (reject) old.reject(new Error('old'))
  516. else old.resolve({ data: { id: 'A', products: [{ isToxic: 1 }] } })
  517. await pending
  518. assert.equal(writes.length, stage === 'write' ? 1 : 0)
  519. assert.deepEqual(h.messages, [])
  520. assert.equal(h.acting.value, true)
  521. h.unmount()
  522. })
  523. for (const bad of [null, {}, { id: 'B', products: [{}] }])
  524. test(`cert ready invalid read ${JSON.stringify(bad)}`, async () => {
  525. const h = harness('plan-cert/detail', { planApi: { get: async () => ({ data: bad }) } })
  526. await h.init()
  527. await h.setReady({ id: 'A' }, true)
  528. assert.deepEqual(h.writes, [])
  529. assert.equal(h.acting.value, false)
  530. h.unmount()
  531. })
  532. test('cert parent ready idempotence, mismatched event, saved identity and query gate', async () => {
  533. let permissionReads = 0,
  534. reloads = 0,
  535. opens = 0
  536. const h = harness('plan-cert/detail', {
  537. fetchGetMenuCodes: async () => {
  538. permissionReads++
  539. return { all: true }
  540. }
  541. })
  542. h.detailRef.value = {
  543. reload: () => {
  544. reloads++
  545. },
  546. openContract: () => {
  547. opens++
  548. },
  549. invalidateRecord() {}
  550. }
  551. h.route.query.contractId = 'contract'
  552. await flush()
  553. assert.equal(opens, 0)
  554. await h.init()
  555. await h.init()
  556. assert.equal(permissionReads, 1)
  557. await h.setReady({ id: 'OTHER' }, true)
  558. assert.deepEqual(h.calls, [])
  559. await h.saved('OTHER')
  560. assert.equal(reloads, 0)
  561. await h.saved('A')
  562. assert.equal(reloads, 1)
  563. h.unmount()
  564. })
  565. for (const reject of [false, true])
  566. test(`cert permissions retry supersedes old ${reject ? 'failure' : 'success'}`, async () => {
  567. const old = deferred()
  568. let n = 0
  569. const h = harness('plan-cert/detail', {
  570. fetchGetMenuCodes: () => (++n === 1 ? old.promise : Promise.resolve({ all: false }))
  571. })
  572. const pending = h.init()
  573. await h.loadPermissions()
  574. if (reject) old.reject(new Error('old'))
  575. else old.resolve({ all: true })
  576. await pending
  577. assert.equal(h.canReady.value, false)
  578. assert.equal(h.permissionError.value, false)
  579. assert.equal(h.permissionsLoaded.value, true)
  580. h.unmount()
  581. })
  582. for (const transition of ['B', 'ABA', 'close-reopen', 'unmount', 'invalidate', 'permission'])
  583. for (const reject of [false, true])
  584. test(`upload ${transition}: stale save ${reject ? 'error' : 'success'}`, async () => {
  585. const old = deferred()
  586. const h = harness('plan-cert/CertUpload', {
  587. planApi: { updateProductCerts: () => old.promise }
  588. })
  589. await flush()
  590. const pending = h.save()
  591. if (transition === 'B' || transition === 'ABA') {
  592. h.props.planId = 'B'
  593. if (transition === 'ABA') h.props.planId = 'A'
  594. }
  595. if (transition === 'close-reopen') {
  596. h.props.modelValue = false
  597. h.props.modelValue = true
  598. }
  599. if (transition === 'unmount') h.unmount()
  600. if (transition === 'invalidate') h.invalidateRecord()
  601. if (transition === 'permission') h.props.permitted = false
  602. await flush()
  603. h.saving.value = true
  604. if (reject) old.reject(new Error('old'))
  605. else old.resolve()
  606. await pending
  607. assert.deepEqual(h.pushes, [])
  608. assert.deepEqual(h.messages, [])
  609. assert.equal(h.error.value, '')
  610. assert.equal(h.saving.value, true)
  611. if (transition !== 'unmount') h.unmount()
  612. })
  613. for (const invalid of [{ id: 'B', products: [] }, { id: 'A' }, null])
  614. test(`upload invalid metadata response ${JSON.stringify(invalid)} blocks saving`, async () => {
  615. const h = harness('plan-cert/CertUpload', { planApi: { get: async () => ({ data: invalid }) } })
  616. await flush()
  617. await h.save()
  618. assert.ok(h.error.value)
  619. assert.equal(h.allowed.value, false)
  620. assert.deepEqual(h.writes, [])
  621. h.unmount()
  622. })
  623. test('upload strict enrichment failure is explicit, retriable and blocks write', async () => {
  624. let strictArg: any
  625. const h = harness('plan-cert/CertUpload', {
  626. enrichPlanProductsHazardFromCrm: async (_: any, strict: any) => {
  627. strictArg = strict
  628. throw new Error('metadata')
  629. }
  630. })
  631. await flush()
  632. await h.save()
  633. assert.equal(strictArg, true)
  634. assert.ok(h.error.value)
  635. assert.deepEqual(h.writes, [])
  636. h.unmount()
  637. })
  638. test('upload keeps edits when same ID/visibility and unrelated query change; payload is captured', async () => {
  639. const old = deferred(),
  640. writes: any[] = []
  641. const h = harness('plan-cert/CertUpload', {
  642. planApi: {
  643. updateProductCerts: (...args: any[]) => {
  644. writes.push(args)
  645. return old.promise
  646. }
  647. }
  648. })
  649. await flush()
  650. h.products.value[0].purchaseCertUrl = 'original'
  651. h.props.planId = 'A'
  652. h.props.modelValue = true
  653. h.route.query.tab = 'info'
  654. await flush()
  655. assert.equal(h.products.value[0].purchaseCertUrl, 'original')
  656. assert.equal(h.calls.length, 1)
  657. const pending = h.save()
  658. h.products.value[0].purchaseCertUrl = 'changed'
  659. old.resolve()
  660. await pending
  661. assert.equal(writes[0][0], 'A')
  662. assert.equal(writes[0][1][0].purchaseCertUrl, 'original')
  663. assert.deepEqual(h.pushes, [
  664. ['update:modelValue', false],
  665. ['success', 'A']
  666. ])
  667. h.unmount()
  668. })
  669. for (const reject of [false, true])
  670. test(`upload stale metadata ${reject ? 'error' : 'success'} cannot clear new loading`, async () => {
  671. const old = deferred(),
  672. fresh = deferred()
  673. let n = 0
  674. const h = harness('plan-cert/CertUpload', {
  675. planApi: { get: () => (++n === 1 ? old.promise : fresh.promise) }
  676. })
  677. h.props.planId = 'B'
  678. if (reject) old.reject(new Error('old'))
  679. else old.resolve({ data: { id: 'A', products: [] } })
  680. await flush()
  681. assert.equal(h.loading.value, true)
  682. assert.equal(h.plan.value, null)
  683. assert.equal(h.error.value, '')
  684. await h.save()
  685. assert.deepEqual(h.writes, [])
  686. fresh.resolve({ data: { id: 'B', products: [] } })
  687. await flush()
  688. assert.equal(h.plan.value.id, 'B')
  689. h.unmount()
  690. })
  691. function recordChildKey(parent: any, component: string) {
  692. const template = parse(
  693. readFileSync(new URL('../src/views/m/pl/plan-cert/detail.vue', import.meta.url), 'utf8')
  694. ).descriptor.template!.content
  695. const key = template.match(new RegExp(`<${component}\\b[^>]*:key="([^"]+)"`))?.[1]
  696. assert.ok(key, `${component} must have a record-instance key`)
  697. return new Function('state', `with (state) { return (${key}) }`)(vue.proxyRefs(parent))
  698. }
  699. for (const change of ['record', 'ABA', 'query'])
  700. test(`cert upload real parent renderer ${change} before child props flush`, async () => {
  701. const saved = deferred()
  702. let child: any
  703. const Child = vue.defineComponent({
  704. props: ['modelValue', 'planId', 'permitted'],
  705. emits: ['update:modelValue', 'success'],
  706. setup(props, context) {
  707. child = harness('plan-cert/CertUpload', {
  708. defineProps: () => props,
  709. defineEmits: () => context.emit,
  710. defineExpose: context.expose,
  711. onBeforeUnmount: vue.onBeforeUnmount,
  712. planApi: { updateProductCerts: () => saved.promise }
  713. })
  714. return () => vue.h('upload')
  715. }
  716. })
  717. const renderer = vue.createRenderer({
  718. insert(el: any, parent: any) {
  719. el.parent = parent
  720. },
  721. remove() {},
  722. createElement: () => ({}),
  723. createText: () => ({}),
  724. createComment: () => ({}),
  725. setText() {},
  726. setElementText() {},
  727. parentNode: (n: any) => n.parent,
  728. nextSibling: () => null,
  729. patchProp() {}
  730. })
  731. const template = parse(
  732. readFileSync(new URL('../src/views/m/pl/plan-cert/detail.vue', import.meta.url), 'utf8')
  733. ).descriptor.template!.content
  734. assert.match(template, /<CertUpload\b[^>]*ref="uploadRef"/)
  735. const parent = harness('plan-cert/detail')
  736. await parent.init()
  737. parent.uploadOpen.value = true
  738. let successes = 0
  739. const App = vue.defineComponent({
  740. setup: () => () =>
  741. vue.h(Child, {
  742. key: recordChildKey(parent, 'CertUpload'),
  743. ref: parent.uploadRef,
  744. planId: parent.id.value,
  745. modelValue: parent.uploadOpen.value,
  746. permitted: parent.canUpload.value,
  747. 'data-tab': parent.route.query.tab,
  748. 'onUpdate:modelValue': (open: boolean) => {
  749. parent.uploadOpen.value = open
  750. },
  751. onSuccess: (id: string) => {
  752. successes++
  753. void parent.saved(id)
  754. }
  755. })
  756. })
  757. const app = renderer.createApp(App)
  758. app.mount({})
  759. await flush()
  760. const oldChild = child,
  761. pending = child.save()
  762. saved.resolve()
  763. if (change !== 'query') {
  764. parent.route.params.id = 'B'
  765. if (change === 'ABA') parent.route.params.id = 'A'
  766. } else parent.route.query.tab = 'notes'
  767. assert.equal(oldChild.props.planId, 'A', 'Vue child props have not flushed')
  768. await pending
  769. await flush()
  770. assert.equal(successes, change !== 'query' ? 0 : 1)
  771. assert.equal(oldChild.messages.length, change !== 'query' ? 0 : 1)
  772. app.unmount()
  773. parent.unmount()
  774. })
  775. for (const reject of [false, true])
  776. test(`cert detail same-record retry isolates old loader ${reject ? 'error' : 'success/finally'}`, async () => {
  777. const old = deferred(),
  778. fresh = deferred()
  779. let n = 0
  780. const h = harness('plan-cert/CertDetail', {
  781. usePlPlanViewDetail: detailLoader(() => (++n === 1 ? old.promise : fresh.promise))
  782. })
  783. await flush()
  784. const pending = h.reload()
  785. if (reject) old.reject(new Error('old'))
  786. else old.resolve({ id: 'A', planNo: 'OLD' })
  787. await flush()
  788. assert.equal(h.plan.value, null)
  789. assert.equal(h.loading.value, true)
  790. assert.equal(h.error.value, '')
  791. fresh.resolve({ id: 'A', planNo: 'FRESH' })
  792. await pending
  793. assert.equal(h.plan.value.planNo, 'FRESH')
  794. h.unmount()
  795. })
  796. test('cert detail parent sync invalidation prevents stale contract and linked callbacks', async () => {
  797. const contract = deferred(),
  798. linked = deferred()
  799. const h = harness('plan-cert/CertDetail', {
  800. usePlPlanViewDetail: detailLoader(async (id) => ({ id })),
  801. getContract: async () => ({ data: { id: 'C' } }),
  802. enrichContractReadFields: () => contract.promise,
  803. planApi: { list: () => linked.promise }
  804. })
  805. await flush()
  806. const pending = h.openContract('C')
  807. await flush()
  808. contract.resolve({ contract: { id: 'C' }, warnings: [] })
  809. await flush()
  810. h.invalidateRecord()
  811. linked.resolve({ data: { list: [{ id: 'OLD' }], total: 1 } })
  812. await pending
  813. assert.equal(h.contractOpen.value, false)
  814. assert.deepEqual(h.linkedPlans.value, [])
  815. assert.equal(h.linkedLoading.value, false)
  816. h.unmount()
  817. })
  818. test('transport local tabs retain single-line 44px targets and bordered content fits available width', () => {
  819. const sfc = parse(
  820. readFileSync(new URL('../src/views/m/pl/plan/detail.vue', import.meta.url), 'utf8')
  821. ).descriptor
  822. const style = sfc.styles.find((s) => s.scoped)!.content
  823. assert.match(
  824. style,
  825. /\.mp-plan-detail-page\.mp-h5-detail-page[\s\S]*?:deep\(\.van-tab\)\s*\{[^}]*flex:\s*0 0 auto !important/
  826. )
  827. assert.match(style, /:deep\(\.van-tab__text\)\s*\{[^}]*white-space:\s*nowrap/)
  828. assert.match(
  829. style,
  830. /:deep\(\.van-tabs__nav\)\s*\{[^}]*height:\s*44px[^}]*box-sizing:\s*border-box/
  831. )
  832. assert.match(style, /\.mp-wv-section\s*\{[^}]*min-width:\s*0[^}]*box-sizing:\s*border-box/)
  833. })
  834. for (const component of ['CertDetail', 'CertUpload'])
  835. test(`${component}: real parent same-tick ABA remounts, unrelated query preserves instance`, async () => {
  836. let child: any,
  837. mounts = 0,
  838. reads = 0
  839. const Child = vue.defineComponent({
  840. props: ['planId', 'modelValue', 'permitted'],
  841. setup(props, context) {
  842. mounts++
  843. const read = async (id: string) => {
  844. reads++
  845. return { id, products: [{ id: 'p', isToxic: 1 }] }
  846. }
  847. child = harness(`plan-cert/${component}`, {
  848. defineProps: () => props,
  849. defineExpose: context.expose,
  850. onBeforeUnmount: vue.onBeforeUnmount,
  851. usePlPlanViewDetail: detailLoader(read, true),
  852. planApi: { get: read }
  853. })
  854. return () => vue.h('child')
  855. }
  856. })
  857. const renderer = vue.createRenderer({
  858. insert(el: any, parent: any) {
  859. el.parent = parent
  860. },
  861. remove() {},
  862. createElement: () => ({}),
  863. createText: () => ({}),
  864. createComment: () => ({}),
  865. setText() {},
  866. setElementText() {},
  867. parentNode: (n: any) => n.parent,
  868. nextSibling: () => null,
  869. patchProp() {}
  870. })
  871. const parent = harness('plan-cert/detail')
  872. await parent.init()
  873. parent.uploadOpen.value = true
  874. const App = vue.defineComponent({
  875. setup: () => () =>
  876. vue.h(Child, {
  877. key: recordChildKey(parent, component),
  878. ref: component === 'CertDetail' ? parent.detailRef : parent.uploadRef,
  879. planId: parent.id.value,
  880. modelValue: parent.uploadOpen.value,
  881. permitted: parent.canUpload.value,
  882. 'data-tab': parent.route.query.tab
  883. })
  884. })
  885. const app = renderer.createApp(App)
  886. app.mount({})
  887. await flush()
  888. assert.equal(child.plan.value.id, 'A')
  889. const first = child
  890. if (component === 'CertUpload') child.products.value[0].purchaseCertUrl = 'preserved'
  891. parent.route.query.tab = 'info'
  892. await flush()
  893. assert.equal(child, first)
  894. assert.equal(reads, 1)
  895. assert.equal(mounts, 1)
  896. if (component === 'CertUpload')
  897. assert.equal(child.products.value[0].purchaseCertUrl, 'preserved')
  898. parent.route.params.id = 'B'
  899. parent.route.params.id = 'A'
  900. await flush()
  901. assert.equal(mounts, 2, 'an invalidated child must never be reused for same-tick ABA')
  902. if (component === 'CertUpload') {
  903. parent.openUpload({ id: 'A' })
  904. await flush()
  905. }
  906. assert.equal(reads, 2)
  907. assert.equal(child.plan.value.id, 'A')
  908. assert.equal(child.loading.value, false)
  909. app.unmount()
  910. parent.unmount()
  911. })
  912. for (const reject of [false, true])
  913. test(`cert detail reload resets copy busy while obsolete ${reject ? 'failure' : 'success'} preserves new copy`, async () => {
  914. const old = deferred(),
  915. fresh = deferred()
  916. const h = harness('plan-cert/CertDetail', {
  917. usePlPlanViewDetail: detailLoader(async (id) => ({ id }))
  918. })
  919. await flush()
  920. const first = h.copyAction(() => old.promise)
  921. assert.equal(h.copying.value, true)
  922. const reload = h.reload()
  923. assert.equal(h.copying.value, false, 'new load must synchronously reset previous copy busy')
  924. await reload
  925. const second = h.copyAction(() => fresh.promise)
  926. assert.equal(h.copying.value, true)
  927. if (reject) old.reject(new Error('old copy'))
  928. else old.resolve()
  929. await first
  930. assert.equal(h.copying.value, true, 'obsolete copy must not clear new copy busy')
  931. assert.deepEqual(h.messages, [])
  932. fresh.resolve()
  933. await second
  934. assert.equal(h.copying.value, false)
  935. h.unmount()
  936. })
  937. test('plan detail identity switch invalidates old reads and closes every record action', async () => {
  938. const older = deferred()
  939. const user = vue.reactive({ accessToken: 'old', info: { id: 'old-user' } })
  940. let reads = 0
  941. const h = harness('plan/detail', {
  942. useUserStore: () => user,
  943. planApi: {
  944. get: () => (++reads === 1 ? older.promise : Promise.resolve({ id: 'A', marker: 'new' }))
  945. },
  946. loadMobileDetailReads: async () => ({ errors: [] })
  947. })
  948. h.ready()
  949. await flush()
  950. h.moreOpen.value = h.voidOpen.value = h.vehicleOpen.value = true
  951. user.accessToken = 'new'
  952. assert.equal(h.moreOpen.value, false)
  953. assert.equal(h.voidOpen.value, false)
  954. assert.equal(h.vehicleOpen.value, false)
  955. await flush()
  956. older.resolve({ id: 'A', marker: 'old' })
  957. await flush()
  958. assert.equal(h.plan.value.marker, 'new')
  959. assert.equal(reads, 2)
  960. user.accessToken = ''
  961. assert.equal(h.plan.value, null)
  962. assert.equal(h.loading.value, false)
  963. assert.match(h.errorText.value, /登录状态/)
  964. await h.runAction('wait')
  965. assert.deepEqual(h.writes, [])
  966. h.unmount()
  967. })
  968. for (const change of ['token', 'user', 'logout'])
  969. test(`cert parent ${change} change invalidates children and pending ready confirmation`, async () => {
  970. const user = vue.reactive({ accessToken: 'old', info: { id: 'old-user' } })
  971. const confirm = deferred()
  972. let permissions = 0,
  973. invalidations = 0
  974. const h = harness('plan-cert/detail', {
  975. useUserStore: () => user,
  976. showCrmConfirmDialog: () => confirm.promise,
  977. fetchGetMenuCodes: async () => (++permissions === 1 ? { all: true } : { menuCodes: [] })
  978. })
  979. await h.init()
  980. h.detailRef.value = h.uploadRef.value = { invalidateRecord: () => invalidations++ }
  981. h.uploadOpen.value = true
  982. const pending = h.setReady({ id: 'A' }, true)
  983. await flush()
  984. const epoch = h.recordEpoch.value
  985. if (change === 'token') user.accessToken = 'new'
  986. else if (change === 'user') user.info = { id: 'new-user' }
  987. else user.accessToken = ''
  988. assert.equal(h.uploadOpen.value, false)
  989. assert.equal(h.canReady.value, false)
  990. assert.ok(h.recordEpoch.value > epoch)
  991. assert.ok(invalidations >= 2)
  992. confirm.resolve()
  993. await pending
  994. await flush()
  995. assert.deepEqual(h.writes, [])
  996. assert.equal(h.canUpload.value, false)
  997. assert.equal(permissions, change === 'logout' ? 1 : 2)
  998. h.unmount()
  999. })
  1000. test('cert parent old permission read cannot grant access after identity changes', async () => {
  1001. const user = vue.reactive({ accessToken: 'old', info: { id: 'user' } })
  1002. const old = deferred()
  1003. let reads = 0
  1004. const h = harness('plan-cert/detail', {
  1005. useUserStore: () => user,
  1006. fetchGetMenuCodes: () => (++reads === 1 ? old.promise : Promise.resolve({ menuCodes: [] }))
  1007. })
  1008. const init = h.init()
  1009. user.accessToken = 'new'
  1010. await flush()
  1011. old.resolve({ all: true })
  1012. await init
  1013. assert.equal(h.canUpload.value, false)
  1014. assert.equal(h.canReady.value, false)
  1015. assert.equal(h.canPrice.value, false)
  1016. assert.equal(reads, 2)
  1017. h.unmount()
  1018. })
  1019. test('cert detail requests mobile metadata once and passes that snapshot to the shared read loader', async () => {
  1020. const snapshot = { id: 'A', products: [], deliveries: [] }
  1021. const reads: any[] = [],
  1022. snapshots: any[] = []
  1023. const loader = detailLoader(async () => snapshot)
  1024. const h = harness('plan-cert/CertDetail', {
  1025. planApi: {
  1026. get: async (...args: any[]) => {
  1027. reads.push(args)
  1028. return { data: snapshot }
  1029. }
  1030. },
  1031. usePlPlanViewDetail: () => {
  1032. const detail = loader()
  1033. const load = detail.loadDetail
  1034. detail.loadDetail = (id: string, options: any) => {
  1035. snapshots.push(options?.initialPlan)
  1036. return load(id)
  1037. }
  1038. return detail
  1039. }
  1040. })
  1041. await flush()
  1042. assert.deepEqual(reads, [['A', true]])
  1043. assert.equal(snapshots[0], snapshot)
  1044. assert.equal(h.plan.value.id, 'A')
  1045. h.unmount()
  1046. })