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('', 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(null), products: ref([]), approvalList: ref([]), 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('')) assert.doesNotMatch(tabs, /v-if="!props.embedded"/) for (const tab of ['contract', 'plans', 'payment', 'invoice', 'approval', 'operation']) { assert.match(template, new RegExp(`
{ 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) })