Files
shanxia2.0/web-admin/scripts/mobile-plan-contract-embedded.test.ts

227 lines
8.1 KiB
TypeScript

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)
})