mobile-plan-contract-embedded.test.ts 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. import assert from 'node:assert/strict'
  2. import test from 'node:test'
  3. import { readFileSync } from 'node:fs'
  4. import { parse, compileScript, compileTemplate } from '@vue/compiler-sfc'
  5. import { compile, createSSRApp } from 'vue'
  6. import { renderToString } from 'vue/server-renderer'
  7. import ts from 'typescript'
  8. const path = new URL('../src/views/m/crm/contract/detail.vue', import.meta.url)
  9. const d = parse(readFileSync(path, 'utf8')).descriptor
  10. const ast = ts.createSourceFile('detail.ts', d.scriptSetup!.content, ts.ScriptTarget.Latest, true)
  11. function fn(name: string, bindings: any) {
  12. const node = ast.statements.find((n) => ts.isFunctionDeclaration(n) && n.name?.text === name)!
  13. return new Function(
  14. ...Object.keys(bindings),
  15. ts.transpile(node.getText(ast)) + `;return ${name}`
  16. )(...Object.values(bindings))
  17. }
  18. test('embedded identity comes only from recordId and normal identity stays route based', () => {
  19. const props = { embedded: true, recordId: 'contract' }
  20. const route = { name: 'MpPlanDetail', path: '/m/pl/plan/plan', params: { id: 'plan' } }
  21. const current = fn('currentRecordId', { props, route })
  22. assert.equal(current(), 'contract')
  23. props.embedded = false
  24. assert.equal(current(), '')
  25. route.name = 'MpCrmContractDetail'
  26. route.path = '/m/crm/contract/plan'
  27. assert.equal(current(), 'plan')
  28. })
  29. test('embedded route actions never navigate or write', async () => {
  30. let calls = 0
  31. await fn('runRouteAction', {
  32. props: { embedded: true },
  33. route: { query: { action: 'delete' } },
  34. captureOperation: () => {
  35. calls++
  36. return {}
  37. },
  38. router: {
  39. replace: async () => {
  40. calls++
  41. }
  42. },
  43. isCurrentOperation: () => true,
  44. runAction: () => calls++
  45. })()
  46. assert.equal(calls, 0)
  47. })
  48. test('embedded template reuses complete detail, readonly attachments and masks all numeric pricing regions', async () => {
  49. compileScript(d, { id: 'embedded' })
  50. assert.deepEqual(
  51. compileTemplate({ source: d.template!.content, filename: path.pathname, id: 'embedded' })
  52. .errors,
  53. []
  54. )
  55. const template = d.template!.content
  56. assert.match(template, /:editable="!props.embedded && !isApproveEntry"/)
  57. assert.match(template, /priceTiers/)
  58. for (const id of ['amount', 'products', 'stats']) {
  59. const position = template.indexOf(`id="contract-sec-${id}"`)
  60. const start = template.lastIndexOf('<section', position)
  61. const fragment = template.slice(start, template.indexOf('</section>', position) + 10)
  62. const bindings = {
  63. props: { embedded: true, hideProductPrice: true },
  64. contract: { amount: 9876, freight: 9876, barrelDeposit: 9876 },
  65. products: [{ productName: '产品', price: 9876, priceTiers: [{ price: 9876 }] }],
  66. formatContractMoney: () => '9876',
  67. productMoney: () => '9876',
  68. displayCategory: () => '',
  69. displayFrameworkQuantity: () => '固定数量',
  70. formatProductPlanQty: () => '0',
  71. resolveLineRemainingQty: () => 0,
  72. formatRemainingQtyLabel: () => '0',
  73. formatPackageSpecsJoin: () => '',
  74. unitLabel: () => '',
  75. formatBrandConstraintLabel: () => '',
  76. isFrameworkContractType: () => true
  77. }
  78. const render = compile(fragment)
  79. const html = await renderToString(
  80. createSSRApp({ render: () => render.call(bindings, bindings, []) })
  81. )
  82. assert.doesNotMatch(html, /9876/)
  83. }
  84. })
  85. test('embedded real Vue identity watcher ignores plan route changes and rejects old request after switch or unmount', async () => {
  86. const { reactive, ref, watch, nextTick, effectScope } = await import('vue')
  87. const props = reactive({ embedded: true, recordId: 'A' })
  88. const route = reactive({ params: { id: 'plan' }, query: {} })
  89. let resolveOld: (v: any) => void = () => {}
  90. const old = new Promise((r) => {
  91. resolveOld = r
  92. })
  93. const hooks: Array<() => void> = []
  94. const names = [
  95. 'currentRecordId',
  96. 'isCurrentLoad',
  97. 'loadDetail',
  98. 'loadApprovalHistory',
  99. 'resetRecord',
  100. 'runRouteAction'
  101. ]
  102. const declarations = ast.statements
  103. .filter((n) => ts.isFunctionDeclaration(n) && names.includes(n.name?.text || ''))
  104. .map((n) => n.getText(ast))
  105. const lifecycle = ast.statements
  106. .filter(
  107. (n) =>
  108. ts.isExpressionStatement(n) &&
  109. ((n.getText(ast).startsWith('watch(') &&
  110. n.getText(ast).includes('() => currentRecordId()')) ||
  111. n.getText(ast).startsWith('onBeforeUnmount('))
  112. )
  113. .map((n) => n.getText(ast))
  114. const calls: string[] = []
  115. const bindings = {
  116. props,
  117. route,
  118. watch,
  119. onBeforeUnmount: (cb: () => void) => hooks.push(cb),
  120. contract: ref<any>(null),
  121. products: ref<any[]>([]),
  122. approvalList: ref<any[]>([]),
  123. approvalError: ref(''),
  124. approvalLoading: ref(false),
  125. linkedPlans: ref([]),
  126. linkedPlansTotal: ref(0),
  127. linkedPlansLoading: ref(false),
  128. linkedPlansError: ref(false),
  129. activeTab: ref('contract'),
  130. partiesExpanded: ref(false),
  131. categoryPaths: ref(new Map()),
  132. errorText: ref(''),
  133. loading: ref(false),
  134. closeRecordActions: () => {},
  135. loadCategoryPaths: async () => {},
  136. getContract: async (id: string) => {
  137. calls.push(id)
  138. return id === 'A' ? old : { id, products: [] }
  139. },
  140. listContractApproval: async () => ({ data: [] }),
  141. getQuote: async () => ({})
  142. }
  143. const scope = effectScope()
  144. const run = scope.run(() =>
  145. new Function(
  146. ...Object.keys(bindings),
  147. 'let detailRequestSeq=0;let approvalRequestSeq=0;let linkedPlansRequestSeq=0;let shellReady=true;let disposed=false;' +
  148. ts.transpile([...declarations, ...lifecycle].join('\n')) +
  149. ';return {loadDetail}'
  150. )(...Object.values(bindings))
  151. )
  152. const pending = run.loadDetail()
  153. route.params.id = 'other-plan'
  154. await nextTick()
  155. assert.deepEqual(calls, ['A'])
  156. props.recordId = 'B'
  157. await nextTick()
  158. await new Promise((r) => setTimeout(r, 0))
  159. assert.equal(bindings.contract.value?.id, 'B')
  160. resolveOld({ id: 'A', products: [{ id: 'stale' }] })
  161. await pending
  162. assert.equal(bindings.contract.value?.id, 'B')
  163. hooks.forEach((h) => h())
  164. assert.equal(bindings.contract.value, null)
  165. scope.stop()
  166. })
  167. test('embedded contract exposes all six tabs and loads linked plans without leaving the popup', async () => {
  168. const template = d.template!.content
  169. const tabs = template.slice(template.indexOf('<van-tabs'), template.indexOf('</van-tabs>'))
  170. assert.doesNotMatch(tabs, /v-if="!props.embedded"/)
  171. for (const tab of ['contract', 'plans', 'payment', 'invoice', 'approval', 'operation']) {
  172. assert.match(template, new RegExp(`<div v-show="activeTab === '${tab}'"`))
  173. }
  174. const linkedPlans = { value: [] }
  175. const bindings = {
  176. props: { embedded: true },
  177. contract: { value: { id: 'C1' } },
  178. linkedPlans,
  179. linkedPlansLoading: { value: false },
  180. linkedPlansError: { value: false },
  181. linkedPlansTotal: { value: 0 },
  182. planApi: {
  183. list: async (params: any) => {
  184. assert.equal(params.contractId, 'C1')
  185. return { data: { list: [{ id: 'P1' }], total: 1 } }
  186. }
  187. }
  188. }
  189. const declaration = ast.statements.find(
  190. (n) => ts.isFunctionDeclaration(n) && n.name?.text === 'loadLinkedPlans'
  191. )!
  192. await new Function(
  193. ...Object.keys(bindings),
  194. 'let linkedPlansRequestSeq=0;' +
  195. ts.transpile(declaration.getText(ast)) +
  196. ';return loadLinkedPlans'
  197. )(...Object.values(bindings))()
  198. assert.deepEqual(linkedPlans.value, [{ id: 'P1' }])
  199. let navigations = 0
  200. fn('goLinkedPlan', { props: { embedded: true }, router: { push: () => navigations++ } })('P1')
  201. assert.equal(navigations, 0)
  202. })
  203. test('embedded tab changes reset only the popup scroller and lazy load plans', () => {
  204. let outerScrolls = 0,
  205. innerScrolls = 0,
  206. planLoads = 0
  207. const scroller = { scrollTo: () => innerScrolls++ }
  208. const activeTab = { value: 'contract' }
  209. const change = fn('onDetailTabChange', {
  210. props: { embedded: true },
  211. activeTab,
  212. detailBody: { value: scroller },
  213. linkedPlans: { value: [] },
  214. linkedPlansLoading: { value: false },
  215. loadLinkedPlans: () => planLoads++,
  216. window: { getComputedStyle: (e: any) => e, scrollTo: () => outerScrolls++ }
  217. })
  218. change('plans')
  219. assert.equal(activeTab.value, 'plans')
  220. assert.equal(planLoads, 1)
  221. assert.equal(innerScrolls, 1)
  222. assert.equal(outerScrolls, 0)
  223. })