| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226 |
- import assert from 'node:assert/strict'
- import test from 'node:test'
- import { readFileSync } from 'node:fs'
- import { parse, compileScript, compileTemplate } from '@vue/compiler-sfc'
- import { compile, createSSRApp } from 'vue'
- import { renderToString } from 'vue/server-renderer'
- import ts from 'typescript'
- const path = new URL('../src/views/m/crm/contract/detail.vue', import.meta.url)
- const d = parse(readFileSync(path, 'utf8')).descriptor
- const ast = ts.createSourceFile('detail.ts', d.scriptSetup!.content, ts.ScriptTarget.Latest, true)
- function fn(name: string, bindings: any) {
- const node = ast.statements.find((n) => ts.isFunctionDeclaration(n) && n.name?.text === name)!
- return new Function(
- ...Object.keys(bindings),
- ts.transpile(node.getText(ast)) + `;return ${name}`
- )(...Object.values(bindings))
- }
- test('embedded identity comes only from recordId and normal identity stays route based', () => {
- const props = { embedded: true, recordId: 'contract' }
- const route = { name: 'MpPlanDetail', path: '/m/pl/plan/plan', params: { id: 'plan' } }
- const current = fn('currentRecordId', { props, route })
- assert.equal(current(), 'contract')
- props.embedded = false
- assert.equal(current(), '')
- route.name = 'MpCrmContractDetail'
- route.path = '/m/crm/contract/plan'
- assert.equal(current(), 'plan')
- })
- test('embedded route actions never navigate or write', async () => {
- let calls = 0
- await fn('runRouteAction', {
- props: { embedded: true },
- route: { query: { action: 'delete' } },
- captureOperation: () => {
- calls++
- return {}
- },
- router: {
- replace: async () => {
- calls++
- }
- },
- isCurrentOperation: () => true,
- runAction: () => calls++
- })()
- assert.equal(calls, 0)
- })
- test('embedded template reuses complete detail, readonly attachments and masks all numeric pricing regions', async () => {
- compileScript(d, { id: 'embedded' })
- assert.deepEqual(
- compileTemplate({ source: d.template!.content, filename: path.pathname, id: 'embedded' })
- .errors,
- []
- )
- const template = d.template!.content
- assert.match(template, /:editable="!props.embedded && !isApproveEntry"/)
- assert.match(template, /priceTiers/)
- for (const id of ['amount', 'products', 'stats']) {
- const position = template.indexOf(`id="contract-sec-${id}"`)
- const start = template.lastIndexOf('<section', position)
- const fragment = template.slice(start, template.indexOf('</section>', position) + 10)
- const bindings = {
- props: { embedded: true, hideProductPrice: true },
- contract: { amount: 9876, freight: 9876, barrelDeposit: 9876 },
- products: [{ productName: '产品', price: 9876, priceTiers: [{ price: 9876 }] }],
- formatContractMoney: () => '9876',
- productMoney: () => '9876',
- displayCategory: () => '',
- displayFrameworkQuantity: () => '固定数量',
- formatProductPlanQty: () => '0',
- resolveLineRemainingQty: () => 0,
- formatRemainingQtyLabel: () => '0',
- formatPackageSpecsJoin: () => '',
- unitLabel: () => '',
- formatBrandConstraintLabel: () => '',
- isFrameworkContractType: () => true
- }
- const render = compile(fragment)
- const html = await renderToString(
- createSSRApp({ render: () => render.call(bindings, bindings, []) })
- )
- assert.doesNotMatch(html, /9876/)
- }
- })
- test('embedded real Vue identity watcher ignores plan route changes and rejects old request after switch or unmount', async () => {
- const { reactive, ref, watch, nextTick, effectScope } = await import('vue')
- const props = reactive({ embedded: true, recordId: 'A' })
- const route = reactive({ params: { id: 'plan' }, query: {} })
- let resolveOld: (v: any) => void = () => {}
- const old = new Promise((r) => {
- resolveOld = r
- })
- const hooks: Array<() => void> = []
- const names = [
- 'currentRecordId',
- 'isCurrentLoad',
- 'loadDetail',
- 'loadApprovalHistory',
- 'resetRecord',
- 'runRouteAction'
- ]
- const declarations = ast.statements
- .filter((n) => ts.isFunctionDeclaration(n) && names.includes(n.name?.text || ''))
- .map((n) => n.getText(ast))
- const lifecycle = ast.statements
- .filter(
- (n) =>
- ts.isExpressionStatement(n) &&
- ((n.getText(ast).startsWith('watch(') &&
- n.getText(ast).includes('() => currentRecordId()')) ||
- n.getText(ast).startsWith('onBeforeUnmount('))
- )
- .map((n) => n.getText(ast))
- const calls: string[] = []
- const bindings = {
- props,
- route,
- watch,
- onBeforeUnmount: (cb: () => void) => hooks.push(cb),
- contract: ref<any>(null),
- products: ref<any[]>([]),
- approvalList: ref<any[]>([]),
- approvalError: ref(''),
- approvalLoading: ref(false),
- linkedPlans: ref([]),
- linkedPlansTotal: ref(0),
- linkedPlansLoading: ref(false),
- linkedPlansError: ref(false),
- activeTab: ref('contract'),
- partiesExpanded: ref(false),
- categoryPaths: ref(new Map()),
- errorText: ref(''),
- loading: ref(false),
- closeRecordActions: () => {},
- loadCategoryPaths: async () => {},
- getContract: async (id: string) => {
- calls.push(id)
- return id === 'A' ? old : { id, products: [] }
- },
- listContractApproval: async () => ({ data: [] }),
- getQuote: async () => ({})
- }
- const scope = effectScope()
- const run = scope.run(() =>
- new Function(
- ...Object.keys(bindings),
- 'let detailRequestSeq=0;let approvalRequestSeq=0;let linkedPlansRequestSeq=0;let shellReady=true;let disposed=false;' +
- ts.transpile([...declarations, ...lifecycle].join('\n')) +
- ';return {loadDetail}'
- )(...Object.values(bindings))
- )
- const pending = run.loadDetail()
- route.params.id = 'other-plan'
- await nextTick()
- assert.deepEqual(calls, ['A'])
- props.recordId = 'B'
- await nextTick()
- await new Promise((r) => setTimeout(r, 0))
- assert.equal(bindings.contract.value?.id, 'B')
- resolveOld({ id: 'A', products: [{ id: 'stale' }] })
- await pending
- assert.equal(bindings.contract.value?.id, 'B')
- hooks.forEach((h) => h())
- assert.equal(bindings.contract.value, null)
- scope.stop()
- })
- test('embedded contract exposes all six tabs and loads linked plans without leaving the popup', async () => {
- const template = d.template!.content
- const tabs = template.slice(template.indexOf('<van-tabs'), template.indexOf('</van-tabs>'))
- assert.doesNotMatch(tabs, /v-if="!props.embedded"/)
- for (const tab of ['contract', 'plans', 'payment', 'invoice', 'approval', 'operation']) {
- assert.match(template, new RegExp(`<div v-show="activeTab === '${tab}'"`))
- }
- const linkedPlans = { value: [] }
- const bindings = {
- props: { embedded: true },
- contract: { value: { id: 'C1' } },
- linkedPlans,
- linkedPlansLoading: { value: false },
- linkedPlansError: { value: false },
- linkedPlansTotal: { value: 0 },
- planApi: {
- list: async (params: any) => {
- assert.equal(params.contractId, 'C1')
- return { data: { list: [{ id: 'P1' }], total: 1 } }
- }
- }
- }
- const declaration = ast.statements.find(
- (n) => ts.isFunctionDeclaration(n) && n.name?.text === 'loadLinkedPlans'
- )!
- await new Function(
- ...Object.keys(bindings),
- 'let linkedPlansRequestSeq=0;' +
- ts.transpile(declaration.getText(ast)) +
- ';return loadLinkedPlans'
- )(...Object.values(bindings))()
- assert.deepEqual(linkedPlans.value, [{ id: 'P1' }])
- let navigations = 0
- fn('goLinkedPlan', { props: { embedded: true }, router: { push: () => navigations++ } })('P1')
- assert.equal(navigations, 0)
- })
- test('embedded tab changes reset only the popup scroller and lazy load plans', () => {
- let outerScrolls = 0,
- innerScrolls = 0,
- planLoads = 0
- const scroller = { scrollTo: () => innerScrolls++ }
- const activeTab = { value: 'contract' }
- const change = fn('onDetailTabChange', {
- props: { embedded: true },
- activeTab,
- detailBody: { value: scroller },
- linkedPlans: { value: [] },
- linkedPlansLoading: { value: false },
- loadLinkedPlans: () => planLoads++,
- window: { getComputedStyle: (e: any) => e, scrollTo: () => outerScrolls++ }
- })
- change('plans')
- assert.equal(activeTab.value, 'plans')
- assert.equal(planLoads, 1)
- assert.equal(innerScrolls, 1)
- assert.equal(outerScrolls, 0)
- })
|