353 lines
11 KiB
TypeScript
353 lines
11 KiB
TypeScript
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<any>((r) => {
|
|
resolve = r
|
|
})
|
|
return { promise, resolve }
|
|
}
|
|
function harness() {
|
|
const permissions: ReturnType<typeof deferred>[] = [],
|
|
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('<el-tab-pane label="运输计划"')
|
|
const end = template.indexOf('<!-- 回款记录 -->', start)
|
|
const compiled = compileTemplate({
|
|
source: `<div>${template.slice(start, end)}</div>`,
|
|
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<string, any> = {
|
|
'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<any>({
|
|
visible: true,
|
|
currentDetailId: 'A',
|
|
canViewQuotaCert: false,
|
|
canViewProof: true,
|
|
detailActiveTab: 'proof',
|
|
detailContract: { id: 'A' }
|
|
})
|
|
const renderer = vue.createRenderer<any, any>({
|
|
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()
|
|
})
|