import test from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { parse, compileTemplate } from '@vue/compiler-sfc' import ts from 'typescript' import * as vue from 'vue' const descriptor = parse( readFileSync( new URL('../src/views/crm/contract/components/ContractDetailDialog.vue', import.meta.url), 'utf8' ) ).descriptor const flush = async () => { for (let i = 0; i < 30; i++) await Promise.resolve() } function deferred() { let resolve!: (value?: any) => void const promise = new Promise((r) => { resolve = r }) return { promise, resolve } } function harness() { const permissions: ReturnType[] = [], contracts: any[] = [], mounted: any[] = [], disposed: any[] = [] const user = vue.reactive({ accessToken: 'a', getUserInfo: { userId: 'u', tenantNo: 't', roles: ['operator'] } }) const dict = deferred() const source = ts.createSourceFile( 'detail.ts', descriptor.scriptSetup!.content, ts.ScriptTarget.Latest, true ) const env: any = Object.fromEntries( Object.entries(vue).filter(([key]) => /^[a-zA-Z_$][\w$]*$/.test(key)) ) delete env.default for (const statement of source.statements) { if (!ts.isImportDeclaration(statement)) continue const clause = statement.importClause if (clause?.name) env[clause.name.text] = () => undefined if (clause?.namedBindings && ts.isNamedImports(clause.namedBindings)) { for (const item of clause.namedBindings.elements) if (!(item.name.text in env)) env[item.name.text] = () => undefined } } Object.assign(env, { defineProps: () => ({}), withDefaults: (v: any) => v, defineEmits: () => () => {}, defineExpose: () => {}, useUserStore: () => user, useDictStore: () => ({ loadAllDict: () => dict.promise }), fetchGetMenuCodes: () => { const p = deferred() permissions.push(p) return p.promise }, getContractDetail: (id: string) => { const p = deferred() contracts.push({ id, ...p }) return p.promise }, listContractApproval: async () => ({ data: [] }), onMounted: (fn: any) => mounted.push(fn), onBeforeUnmount: (fn: any) => disposed.push(fn) }) const code = ts.transpile( source.statements .filter((s) => !ts.isImportDeclaration(s)) .map((s) => s.getText(source)) .join('\n'), { target: ts.ScriptTarget.ESNext } ) const scope = vue.effectScope() const state = scope.run(() => new Function( ...Object.keys(env), `${code}; return { visible, detailActiveTab, detailContract, currentDetailId, canViewQuotaCert, canViewProof: typeof canViewProof === 'undefined' ? ref(false) : canViewProof, open, loadDetailData, detailLoading, handleDetailCopy, formDialogCopySource }` )(...Object.values(env)) ) mounted.forEach((fn) => fn()) return { state, user, permissions, contracts, dict, stop() { disposed.forEach((fn) => fn()) scope.stop() } } } test('independent permissions initialize without waiting for dictionaries', async () => { for (const [code, quota, proof] of [ ['pl:legal-use-document:list', false, true], ['pl:quota-cert:list', true, false], ['none', false, false] ] as const) { const h = harness() h.state.visible.value = true await flush() assert.equal(h.permissions.length, 1) h.permissions[0].resolve({ menuCodes: [code] }) await flush() assert.equal(h.state.canViewQuotaCert.value, quota) assert.equal(h.state.canViewProof.value, proof) h.dict.resolve() h.stop() } }) for (const change of ['tenant', 'token', 'user', 'roles', 'close']) test(`permissions discard old responses after ${change} and reopen`, async () => { const h = harness(), s = h.state s.visible.value = true await flush() assert.equal(h.permissions.length, 1) if (change === 'tenant') h.user.getUserInfo.tenantNo = 'new' if (change === 'token') h.user.accessToken = 'new' if (change === 'roles') h.user.getUserInfo.roles = [] if (change === 'user') h.user.getUserInfo.userId = 'new' if (change === 'close') { s.visible.value = false s.visible.value = true } if (change !== 'close') { assert.equal(s.visible.value, false) s.visible.value = true } await flush() assert.equal(h.permissions.length >= 2, true) h.permissions[h.permissions.length - 1].resolve({ menuCodes: ['pl:legal-use-document:list'] }) await flush() h.permissions[0].resolve({ all: true }) await flush() assert.equal(s.canViewQuotaCert.value, false) assert.equal(s.canViewProof.value, true) s.detailActiveTab.value = 'proof' s.visible.value = false assert.equal(s.canViewProof.value, false) assert.equal(s.detailActiveTab.value, 'contract') h.stop() }) test('switching contracts clears old scope immediately and discards reversed detail responses', async () => { const h = harness(), s = h.state const first = s.open('one') h.contracts[0].resolve({ data: { id: 'one' } }) await first s.detailActiveTab.value = 'proof' const second = s.open('two') assert.equal(s.detailContract.value, undefined) const third = s.open('three') h.contracts[2].resolve({ data: { id: 'three' } }) await third h.contracts[1].resolve({ data: { id: 'two' } }) await second assert.equal(s.detailContract.value.id, 'three') h.stop() }) // Compile the actual three panes to exercise v-if/props/key, without the unrelated 4,000-line dialog. const template = descriptor.template!.content const start = template.indexOf('', start) const compiled = compileTemplate({ source: `
${template.slice(start, end)}
`, filename: 'tabs.vue', id: 'tabs' }) const renderSource = compiled.code .replace( /import \{([^}]+)\} from "vue"/g, (_, bindings) => `const {${bindings.replace(/ as /g, ':')}} = Vue` ) .replace('export function render', 'function render') const render = new Function('Vue', `${renderSource}; return render`)({ ...vue, resolveComponent: (name: string) => ({ name }) }) function panes(ctx: any) { const tree = render(ctx, []) return tree.children.filter((v: any) => typeof v.type !== 'symbol') } function panel(pane: any) { return pane.children.default().find((v: any) => typeof v.type !== 'symbol') } test('real template keeps plans quota proof order and separate lazy contract scopes', () => { const ctx = { visible: true, currentDetailId: 42, canViewQuotaCert: true, canViewProof: true, detailActiveTab: 'proof', detailContract: { id: 42 } } const rows = panes(ctx) assert.deepEqual( rows.map((v: any) => v.props.name), ['plans', 'quota', 'proof'] ) assert.equal(rows[2].props.label, '运输计划合法使用证明') assert.equal(rows[2].props.lazy, '') assert.equal(panel(rows[1]), undefined) const proof = panel(rows[2]) assert.equal(proof.props['contract-id'], '42') assert.equal(proof.key, 42) assert.equal(proof.props.active, true) assert.equal(proof.props.embedded, '') ctx.detailContract.id = 43 ctx.currentDetailId = 43 assert.equal(panel(panes(ctx)[2]).key, 43) ctx.canViewQuotaCert = false assert.deepEqual( panes(ctx).map((v: any) => v.props.name), ['plans', 'proof'] ) ctx.detailActiveTab = 'quota' assert.equal(panel(panes(ctx)[1]), undefined) }) test('closing A and opening B does not accept A detail or invalidate B permissions', async () => { const h = harness(), s = h.state const a = s.open('A') s.visible.value = false const b = s.open('B') h.contracts[1].resolve({ data: { id: 'B' } }) await b h.permissions[h.permissions.length - 1].resolve({ menuCodes: ['pl:legal-use-document:list'] }) await flush() h.contracts[0].resolve({ data: { id: 'A' } }) await a assert.equal(s.detailContract.value.id, 'B') assert.equal(s.canViewProof.value, true) h.stop() }) test('mounted template unmounts old contract and never mounts quota for proof-only users', async () => { const lifecycle: string[] = [] const Child = (name: string) => vue.defineComponent({ props: ['contractId', 'active', 'embedded'], setup(props) { lifecycle.push(`mount:${name}:${props.contractId}`) vue.onBeforeUnmount(() => lifecycle.push(`unmount:${name}:${props.contractId}`)) return () => vue.h('span') } }) const components: Record = { 'el-tab-pane': vue.defineComponent({ setup(_, { slots }) { return () => vue.h('section', slots.default?.()) } }), StopDocuments: Child('proof'), ContractQuotaPanel: Child('quota'), PlContractLinkedPlansPanel: vue.defineComponent({ setup() { return () => vue.h('span') } }) } const mountedRender = new Function('Vue', `${renderSource}; return render`)({ ...vue, resolveComponent: (name: string) => components[name] }) const ctx = vue.reactive({ visible: true, currentDetailId: 'A', canViewQuotaCert: false, canViewProof: true, detailActiveTab: 'proof', detailContract: { id: 'A' } }) const renderer = vue.createRenderer({ createElement: () => ({ children: [] }), createText: () => ({}), createComment: () => ({}), insert(node, parent, anchor) { node.parent = parent const old = parent.children.indexOf(node) if (old >= 0) parent.children.splice(old, 1) const i = parent.children.indexOf(anchor) parent.children.splice(i < 0 ? parent.children.length : i, 0, node) }, remove(node) { const list = node.parent?.children if (list) list.splice(list.indexOf(node), 1) }, setText() {}, setElementText() {}, patchProp() {}, parentNode: (node) => node.parent, nextSibling(node) { return node.parent?.children[node.parent.children.indexOf(node) + 1] } }) const app = renderer.createApp({ setup: () => () => mountedRender(ctx, []) }) app.mount({ children: [] }) assert.deepEqual(lifecycle, ['mount:proof:A']) ctx.currentDetailId = 'B' await vue.nextTick() assert.deepEqual(lifecycle, ['mount:proof:A', 'unmount:proof:A']) ctx.detailContract = { id: 'B' } await vue.nextTick() assert.equal(lifecycle.at(-1), 'mount:proof:B') ctx.detailActiveTab = 'contract' await vue.nextTick() assert.equal(lifecycle.at(-1), 'unmount:proof:B') assert.equal( lifecycle.some((entry) => entry.includes('quota')), false ) app.unmount() }) test('copy action retains the loaded contract after hiding the detail dialog', async () => { const h = harness(), s = h.state const opened = s.open('copy') h.contracts[0].resolve({ data: { id: 'copy' } }) await opened s.handleDetailCopy() assert.equal(s.formDialogCopySource.value?.id, 'copy') h.stop() }) test('session change closes loading detail and old finally cannot revive it', async () => { const h = harness(), s = h.state const pending = s.open('old') assert.equal(s.detailLoading.value, true) h.user.getUserInfo.tenantNo = 'another' assert.equal(s.visible.value, false) assert.equal(s.detailLoading.value, false) h.contracts[0].resolve({ data: { id: 'old' } }) await pending assert.equal(s.detailContract.value, undefined) h.stop() })