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

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