227 lines
8.1 KiB
TypeScript
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)
|
|
})
|