568 lines
19 KiB
TypeScript
568 lines
19 KiB
TypeScript
import test from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import { readFileSync } from 'node:fs'
|
|
import { parse } from '@vue/compiler-sfc'
|
|
import ts from 'typescript'
|
|
import * as vue from 'vue'
|
|
import { usePersistedListColumns } from '../src/composables/usePersistedListColumns'
|
|
import { useServerColumnFilters } from '../src/composables/useServerColumnFilters'
|
|
const flush = async () => {
|
|
for (let i = 0; i < 30; i++) await Promise.resolve()
|
|
}
|
|
function deferred() {
|
|
let resolve!: (value: any) => void, reject!: (error: any) => void
|
|
const promise = new Promise((a, b) => {
|
|
resolve = a
|
|
reject = b
|
|
})
|
|
return { promise, resolve, reject }
|
|
}
|
|
function harness(fixed?: string) {
|
|
const viewportWidth = vue.ref(1120)
|
|
const calls: any[] = [],
|
|
permissions: any[] = [],
|
|
unmount: (() => void)[] = []
|
|
const props = vue.reactive({
|
|
contractId: fixed,
|
|
active: true,
|
|
embedded: !!fixed
|
|
})
|
|
const user = vue.reactive({ accessToken: 'token', getUserInfo: { tenantNo: 'a' } })
|
|
const queue = (method: string, args: any[]) => {
|
|
const pending = deferred()
|
|
calls.push({ method, args, ...pending })
|
|
return pending.promise
|
|
}
|
|
const api = Object.fromEntries(
|
|
['listStops', 'getStops', 'saveStops'].map((method) => [
|
|
method,
|
|
(...args: any[]) => queue(method, args)
|
|
])
|
|
)
|
|
const env = {
|
|
...Object.fromEntries(
|
|
Object.entries(vue).filter(([key]) => /^[a-zA-Z_$][\w$]*$/.test(key) && key !== 'default')
|
|
),
|
|
defineProps: () => props,
|
|
withDefaults: (v: any) => v,
|
|
useUserStore: () => user,
|
|
fetchGetMenuCodes: () => {
|
|
const p = deferred()
|
|
permissions.push(p)
|
|
return p.promise
|
|
},
|
|
useElementSize: () => ({ width: viewportWidth }),
|
|
useListTableViewportHeight: () => ({ tableHeight: vue.ref(400) }),
|
|
usePersistedListColumns,
|
|
useServerColumnFilters,
|
|
legalDocumentApi: api,
|
|
ElMessage: { success() {}, warning() {} },
|
|
onBeforeUnmount: (fn: () => void) => unmount.push(fn)
|
|
}
|
|
const src = ts.createSourceFile(
|
|
'panel.ts',
|
|
parse(
|
|
readFileSync(
|
|
new URL('../src/views/pl/legal-use-document/StopDocuments.vue', import.meta.url),
|
|
'utf8'
|
|
)
|
|
).descriptor.scriptSetup!.content,
|
|
ts.ScriptTarget.Latest,
|
|
true
|
|
)
|
|
const code = ts.transpile(
|
|
src.statements
|
|
.filter((s) => !ts.isImportDeclaration(s))
|
|
.map((s) => s.getText(src))
|
|
.join('\n'),
|
|
{ target: ts.ScriptTarget.ESNext }
|
|
)
|
|
const scope = vue.effectScope()
|
|
const state = scope.run(() =>
|
|
new Function(
|
|
...Object.keys(env),
|
|
`${code}; return { rows, details, load, loadDetail, allowed, canUpload, keyword, setFilterState, page, total, expansionStyle, open, save, visible, editing, upload, saving, editorLoading, status, search, statusLabel, reset, visibleColumns }`
|
|
)(...Object.values(env))
|
|
)
|
|
return {
|
|
state,
|
|
viewportWidth,
|
|
calls,
|
|
permissions,
|
|
props,
|
|
user,
|
|
stop: () => {
|
|
unmount.forEach((fn) => fn())
|
|
scope.stop()
|
|
}
|
|
}
|
|
}
|
|
|
|
test('global and fixed contract preserve server counts and scope every request', async () => {
|
|
for (const fixed of [undefined, 'contract-one']) {
|
|
const h = harness(fixed),
|
|
s = h.state
|
|
h.permissions[0].resolve({ menuCodes: ['pl:legal-use-document:list', 'pl:plan-cert:upload'] })
|
|
await flush()
|
|
assert.equal(h.calls[0].method, 'listStops')
|
|
assert.equal(h.calls[0].args[0].contractId, fixed)
|
|
h.calls[0].resolve({
|
|
list: [{ id: 'plan', uploadedCount: 1, requiredCount: fixed ? 1 : 3 }],
|
|
total: 45
|
|
})
|
|
await flush()
|
|
assert.equal(s.rows.value[0].requiredCount, fixed ? 1 : 3)
|
|
const pending = s.open({ id: 'plan' }, { stopType: 'delivery', stopProductId: 'd2' })
|
|
assert.equal(h.calls[1].args[1], fixed)
|
|
h.calls[1].resolve({
|
|
id: 'plan',
|
|
items: [
|
|
{ stopType: 'delivery', stopProductId: 'd2', productId: 'same', legalUseCertUrl: 'a,b' }
|
|
]
|
|
})
|
|
await pending
|
|
const saving = s.save()
|
|
assert.deepEqual(h.calls[2].args, [
|
|
'plan',
|
|
[{ stopType: 'delivery', stopProductId: 'd2', legalUseCertUrl: 'a,b' }],
|
|
fixed
|
|
])
|
|
h.calls[2].resolve({})
|
|
await saving
|
|
h.stop()
|
|
}
|
|
})
|
|
test('busy uploads refuse saves and failures preserve draft', async () => {
|
|
const h = harness(),
|
|
s = h.state
|
|
h.permissions[0].resolve({ all: true })
|
|
await flush()
|
|
h.calls[0].resolve({ list: [], total: 0 })
|
|
await flush()
|
|
const opened = s.open({ id: 'p' }, { stopType: 'load', stopProductId: 'l' })
|
|
h.calls[1].resolve({
|
|
id: 'p',
|
|
items: [{ stopType: 'load', stopProductId: 'l', legalUseCertUrl: 'original' }]
|
|
})
|
|
await opened
|
|
s.editing.value.legalUseCertUrl = 'draft'
|
|
s.upload.value = { hasUploading: () => true }
|
|
await s.save()
|
|
assert.equal(h.calls.length, 2)
|
|
s.upload.value = { hasUploading: () => false }
|
|
const saving = s.save()
|
|
h.calls[2].reject(Error('failed'))
|
|
await saving
|
|
assert.equal(s.editing.value.legalUseCertUrl, 'draft')
|
|
assert.equal(s.visible.value, true)
|
|
h.stop()
|
|
})
|
|
for (const change of ['contract', 'tenant', 'close'])
|
|
test(`old editor response is discarded after ${change}`, async () => {
|
|
const h = harness('one'),
|
|
s = h.state
|
|
h.permissions[0].resolve({ all: true })
|
|
await flush()
|
|
const old = s.open({ id: 'p' }, { stopType: 'load', stopProductId: 'l' })
|
|
if (change === 'contract') h.props.contractId = 'two'
|
|
if (change === 'tenant') h.user.getUserInfo.tenantNo = 'b'
|
|
if (change === 'close') s.visible.value = false
|
|
h.calls[1].resolve({ id: 'p', items: [{ stopType: 'load', stopProductId: 'l' }] })
|
|
await old
|
|
assert.equal(s.editing.value, undefined)
|
|
h.stop()
|
|
})
|
|
test('search during permission initialization does not cancel it; filters are independent', async () => {
|
|
const h = harness(),
|
|
s = h.state
|
|
s.keyword.value = 'word'
|
|
s.setFilterState('planNo', { text: 'PL' })
|
|
await s.load()
|
|
h.permissions[0].resolve({ all: true })
|
|
await flush()
|
|
assert.equal(h.calls.length, 1)
|
|
assert.equal(h.calls[0].args[0].keyword, 'word')
|
|
assert.equal(h.calls[0].args[0].planNo, 'PL')
|
|
h.stop()
|
|
})
|
|
test('refresh preserves page and total; not_required is not uploaded', async () => {
|
|
const h = harness(),
|
|
s = h.state
|
|
h.permissions[0].resolve({ all: true })
|
|
await flush()
|
|
h.calls[0].resolve({ list: [], total: 45 })
|
|
await flush()
|
|
s.page.value = 2
|
|
const pending = s.load()
|
|
assert.equal(s.total.value, 45)
|
|
assert.equal(h.calls[1].args[0].page, 2)
|
|
h.calls[1].reject(Error('failed'))
|
|
await pending
|
|
assert.equal(s.page.value, 2)
|
|
assert.equal(s.total.value, 45)
|
|
assert.equal(s.statusLabel('not_required'), '无需上传')
|
|
h.stop()
|
|
})
|
|
|
|
// Mount the real Element Plus pager. Its page-count watcher must remain active.
|
|
import ElPagination from 'element-plus/es/components/pagination/src/pagination.mjs'
|
|
test('real pagination keeps page two while loading and preserves count after request failure', async () => {
|
|
const h = harness(),
|
|
s = h.state
|
|
h.permissions[0].resolve({ all: true })
|
|
await flush()
|
|
h.calls[0].resolve({ list: [{ id: 'first' }], total: 45 })
|
|
await flush()
|
|
const renderer = vue.createRenderer<any, any>({
|
|
createElement: () => ({}),
|
|
createText: () => ({}),
|
|
createComment: () => ({}),
|
|
setText: () => {},
|
|
setElementText: () => {},
|
|
parentNode: () => null,
|
|
nextSibling: () => null,
|
|
insert: () => {},
|
|
remove: () => {},
|
|
patchProp: () => {}
|
|
})
|
|
const app = renderer.createApp({
|
|
render: () =>
|
|
vue.h(ElPagination, {
|
|
currentPage: s.page.value,
|
|
total: s.total.value,
|
|
pageSize: 20,
|
|
layout: '',
|
|
'onUpdate:current-page': (value: number) => {
|
|
s.page.value = value
|
|
},
|
|
onCurrentChange: () => {
|
|
void s.load()
|
|
}
|
|
})
|
|
})
|
|
app.mount({})
|
|
s.page.value = 2
|
|
const pending = s.load()
|
|
await vue.nextTick()
|
|
await flush()
|
|
assert.equal(s.page.value, 2)
|
|
assert.equal(h.calls.length, 2)
|
|
assert.equal(h.calls[1].args[0].page, 2)
|
|
h.calls[1].resolve({ list: [{ id: 'second' }], total: 45 })
|
|
await pending
|
|
await vue.nextTick()
|
|
assert.equal(s.page.value, 2)
|
|
assert.equal(s.rows.value[0].id, 'second')
|
|
const failed = s.load()
|
|
h.calls[2].reject(Error('failed'))
|
|
await failed
|
|
await vue.nextTick()
|
|
assert.equal(s.page.value, 2)
|
|
assert.equal(s.total.value, 45)
|
|
assert.deepEqual(s.rows.value, [])
|
|
h.user.getUserInfo.tenantNo = 'next'
|
|
await vue.nextTick()
|
|
assert.equal(s.page.value, 1)
|
|
assert.equal(s.total.value, 0)
|
|
app.unmount()
|
|
h.stop()
|
|
})
|
|
|
|
import { compileScript } from '@vue/compiler-sfc'
|
|
function compiledSfc(path: string, dependencies: Record<string, any>, inlineTemplate = true) {
|
|
const descriptor = parse(readFileSync(new URL(path, import.meta.url), 'utf8')).descriptor
|
|
const compiled = compileScript(descriptor, { id: path, inlineTemplate })
|
|
const source = ts.createSourceFile('compiled.ts', compiled.content, ts.ScriptTarget.Latest, true)
|
|
const env: Record<string, any> = Object.fromEntries(
|
|
Object.entries(vue).filter(([key]) => /^[a-zA-Z_$][\w$]*$/.test(key) && key !== 'default')
|
|
)
|
|
Object.assign(env, dependencies)
|
|
for (const statement of source.statements) {
|
|
if (!ts.isImportDeclaration(statement) || statement.importClause?.isTypeOnly) continue
|
|
const clause = statement.importClause
|
|
if (clause?.name) env[clause.name.text] = dependencies[clause.name.text] || {}
|
|
if (clause?.namedBindings && ts.isNamedImports(clause.namedBindings)) {
|
|
for (const item of clause.namedBindings.elements) {
|
|
if (item.isTypeOnly) continue
|
|
const name = (item.propertyName || item.name).text
|
|
env[item.name.text] =
|
|
statement.moduleSpecifier.getText(source) === '"vue"' ||
|
|
statement.moduleSpecifier.getText(source) === "'vue'"
|
|
? (vue as any)[name]
|
|
: dependencies[name]
|
|
}
|
|
}
|
|
}
|
|
const code = ts.transpile(
|
|
source.statements
|
|
.filter((s) => !ts.isImportDeclaration(s))
|
|
.map((s) => s.getText(source))
|
|
.join('\n')
|
|
.replace('export default', 'const component ='),
|
|
{ target: ts.ScriptTarget.ESNext }
|
|
)
|
|
return new Function(...Object.keys(env), `${code}; return component`)(...Object.values(env))
|
|
}
|
|
|
|
for (const active of [undefined, false])
|
|
test(`compiled proof component active=${active}`, async () => {
|
|
const calls: string[] = []
|
|
const component = compiledSfc(
|
|
'../src/views/pl/legal-use-document/StopDocuments.vue',
|
|
{
|
|
useUserStore: () => vue.reactive({ accessToken: 'token', getUserInfo: { tenantNo: 'a' } }),
|
|
fetchGetMenuCodes: async () => ({ menuCodes: ['pl:legal-use-document:list'] }),
|
|
legalDocumentApi: {
|
|
listStops: async () => {
|
|
calls.push('proof')
|
|
return { list: [], total: 0 }
|
|
}
|
|
},
|
|
usePersistedListColumns,
|
|
useServerColumnFilters,
|
|
useElementSize: () => ({ width: vue.ref(1000) }),
|
|
useListTableViewportHeight: () => ({ tableHeight: vue.ref(400) })
|
|
},
|
|
false
|
|
)
|
|
component.render = () => null
|
|
const renderer = vue.createRenderer<any, any>({
|
|
createElement: () => ({}),
|
|
createText: () => ({}),
|
|
createComment: () => ({}),
|
|
setText() {},
|
|
setElementText() {},
|
|
parentNode: () => null,
|
|
nextSibling: () => null,
|
|
insert() {},
|
|
remove() {},
|
|
patchProp() {}
|
|
})
|
|
const app = renderer.createApp(component, active === undefined ? {} : { active })
|
|
app.mount({})
|
|
await flush()
|
|
assert.deepEqual(calls, active === false ? [] : ['proof'])
|
|
app.unmount()
|
|
})
|
|
test('closing and reopening rejects old response and finally without clearing new loading', async () => {
|
|
const h = harness(),
|
|
s = h.state
|
|
h.permissions[0].resolve({ all: true })
|
|
await flush()
|
|
const first = s.open({ id: 'p' }, { stopType: 'load', stopProductId: 'l' })
|
|
s.visible.value = false
|
|
const second = s.open({ id: 'p' }, { stopType: 'delivery', stopProductId: 'd' })
|
|
h.calls[1].resolve({ items: [{ stopType: 'load', stopProductId: 'l' }] })
|
|
await first
|
|
assert.equal(s.editorLoading.value, true)
|
|
assert.equal(s.editing.value, undefined)
|
|
h.calls[2].resolve({ items: [{ stopType: 'delivery', stopProductId: 'd' }] })
|
|
await second
|
|
assert.equal(s.editorLoading.value, false)
|
|
assert.equal(s.editing.value.stopProductId, 'd')
|
|
h.stop()
|
|
})
|
|
test('historical attachments have a separate readonly field and viewport aligned expansion', () => {
|
|
const descriptor = parse(
|
|
readFileSync(
|
|
new URL('../src/views/pl/legal-use-document/StopDocuments.vue', import.meta.url),
|
|
'utf8'
|
|
)
|
|
).descriptor
|
|
assert.match(descriptor.template!.content, /历史计划级资料/)
|
|
assert.match(
|
|
descriptor.template!.content,
|
|
/:model-value="product.legalUseCertUrl \|\| ''"\s+disabled/
|
|
)
|
|
assert.match(descriptor.styles[0].content, /text-align: left/)
|
|
const h = harness()
|
|
assert.equal(h.state.expansionStyle.value.width, '1120px')
|
|
h.viewportWidth.value = 760
|
|
assert.equal(h.state.expansionStyle.value.width, '760px')
|
|
h.stop()
|
|
})
|
|
test('stop API preserves original legacy methods and scopes all three new endpoints', async () => {
|
|
const calls: any[] = []
|
|
const source = ts.createSourceFile(
|
|
'api.ts',
|
|
readFileSync(new URL('../src/api/pl/quota-cert.ts', import.meta.url), 'utf8'),
|
|
ts.ScriptTarget.Latest,
|
|
true
|
|
)
|
|
const code = ts.transpile(
|
|
source.statements
|
|
.filter((s) => !ts.isImportDeclaration(s))
|
|
.map((s) => s.getText(source).replace(/^export /, ''))
|
|
.join('\n'),
|
|
{ target: ts.ScriptTarget.ESNext }
|
|
)
|
|
const api = new Function('request', 'pickBxData', `${code};return legalDocumentApi`)(
|
|
{
|
|
get: (...args: any[]) => {
|
|
calls.push(args)
|
|
return Promise.resolve({})
|
|
},
|
|
patch: (...args: any[]) => {
|
|
calls.push(args)
|
|
return Promise.resolve({})
|
|
}
|
|
},
|
|
(v: any) => v
|
|
)
|
|
await api.listStops({ contractId: 'c', page: 2, status: 'pending', planNo: 'PL' })
|
|
await api.getStops('p', 'c')
|
|
await api.saveStops(
|
|
'p',
|
|
[{ stopType: 'delivery', stopProductId: 'd', legalUseCertUrl: 'a,b' }],
|
|
'c'
|
|
)
|
|
assert.deepEqual(calls, [
|
|
[
|
|
'/transport/legal-use-documents/stops',
|
|
{ params: { contractId: 'c', page: 2, status: 'pending', planNo: 'PL' } }
|
|
],
|
|
['/transport/legal-use-documents/stops/p', { params: { contractId: 'c' } }],
|
|
[
|
|
'/transport/legal-use-documents/stops/p',
|
|
{ items: [{ stopType: 'delivery', stopProductId: 'd', legalUseCertUrl: 'a,b' }] },
|
|
{ params: { contractId: 'c' } }
|
|
]
|
|
])
|
|
for (const method of ['list', 'get', 'save']) assert.equal(typeof api[method], 'function')
|
|
})
|
|
|
|
test('view permission loads proof only and prevents upload requests', async () => {
|
|
const h = harness(),
|
|
s = h.state
|
|
h.permissions[0].resolve({ menuCodes: ['pl:legal-use-document:list'] })
|
|
await flush()
|
|
assert.equal(s.allowed.value, true)
|
|
assert.equal(s.canUpload.value, false)
|
|
await s.open({ id: 'p' }, { stopType: 'load', stopProductId: 'l' })
|
|
await s.save()
|
|
assert.deepEqual(
|
|
h.calls.map((c) => c.method),
|
|
['listStops']
|
|
)
|
|
h.stop()
|
|
})
|
|
test('old save completion cannot close or clear a reopened editor', async () => {
|
|
const h = harness(),
|
|
s = h.state
|
|
h.permissions[0].resolve({ all: true })
|
|
await flush()
|
|
const opened = s.open({ id: 'p' }, { stopType: 'load', stopProductId: 'l' })
|
|
h.calls[1].resolve({ items: [{ stopType: 'load', stopProductId: 'l', legalUseCertUrl: 'a' }] })
|
|
await opened
|
|
const oldSave = s.save()
|
|
s.visible.value = false
|
|
const reopened = s.open({ id: 'p' }, { stopType: 'delivery', stopProductId: 'd' })
|
|
h.calls[3].resolve({
|
|
items: [{ stopType: 'delivery', stopProductId: 'd', legalUseCertUrl: 'b' }]
|
|
})
|
|
await reopened
|
|
const newSave = s.save()
|
|
h.calls[2].resolve({})
|
|
await oldSave
|
|
assert.equal(s.visible.value, true)
|
|
assert.equal(s.saving.value, true)
|
|
assert.equal(s.editing.value.stopProductId, 'd')
|
|
assert.equal(h.calls.length, 5)
|
|
h.calls[4].reject(Error('retry'))
|
|
await newSave
|
|
assert.equal(s.editing.value.legalUseCertUrl, 'b')
|
|
assert.equal(s.saving.value, false)
|
|
h.stop()
|
|
})
|
|
test('old list and permission responses cannot reauthorize another tenant', async () => {
|
|
const h = harness(),
|
|
s = h.state
|
|
h.user.getUserInfo.tenantNo = 'b'
|
|
h.permissions[0].resolve({ all: true })
|
|
await flush()
|
|
assert.equal(h.calls.length, 0)
|
|
h.permissions[1].resolve({ all: true })
|
|
await flush()
|
|
h.user.getUserInfo.tenantNo = 'c'
|
|
h.calls[0].resolve({ list: [{ id: 'stale' }], total: 1 })
|
|
await flush()
|
|
assert.deepEqual(s.rows.value, [])
|
|
assert.equal(s.allowed.value, false)
|
|
h.permissions[2].resolve({ menuCodes: [] })
|
|
await flush()
|
|
assert.equal(h.calls.length, 1)
|
|
h.stop()
|
|
})
|
|
|
|
test('status is a server single filter and combines with the number filter', async () => {
|
|
const h = harness(),
|
|
s = h.state
|
|
h.permissions[0].resolve({ all: true })
|
|
await flush()
|
|
s.status.value = 'uploaded'
|
|
s.search()
|
|
assert.equal(h.calls.at(-1).args[0].status, 'uploaded')
|
|
s.setFilterState('planNo', { text: 'PL' })
|
|
assert.equal(h.calls.at(-1).args[0].planNo, 'PL')
|
|
assert.equal(h.calls.at(-1).args[0].status, 'uploaded')
|
|
h.calls.forEach((c) => c.resolve({ list: [], total: 0 }))
|
|
await flush()
|
|
h.stop()
|
|
})
|
|
|
|
test('latest refresh wins and hidden column/reset preserve server filter semantics', async () => {
|
|
const h = harness(),
|
|
s = h.state
|
|
h.permissions[0].resolve({ all: true })
|
|
await flush()
|
|
const fresh = s.load()
|
|
h.calls[1].resolve({ list: [{ id: 'new' }], total: 45 })
|
|
await fresh
|
|
h.calls[0].resolve({ list: [{ id: 'old' }], total: 1 })
|
|
await flush()
|
|
assert.equal(s.rows.value[0].id, 'new')
|
|
assert.equal(s.total.value, 45)
|
|
s.keyword.value = 'quick'
|
|
s.status.value = 'pending'
|
|
s.page.value = 2
|
|
s.setFilterState('planNo', { text: 'PL' })
|
|
assert.equal(s.page.value, 1)
|
|
assert.equal(h.calls.at(-1).args[0].planNo, 'PL')
|
|
s.visibleColumns.value = s.visibleColumns.value.filter((key: string) => key !== 'planNo')
|
|
await vue.nextTick()
|
|
assert.equal(h.calls.at(-1).args[0].planNo, undefined)
|
|
assert.equal(h.calls.at(-1).args[0].status, 'pending')
|
|
s.reset()
|
|
assert.equal(h.calls.at(-1).args[0].keyword, '')
|
|
assert.equal(h.calls.at(-1).args[0].status, undefined)
|
|
h.calls.forEach((c) => c.resolve({ list: [], total: 0 }))
|
|
await flush()
|
|
h.stop()
|
|
})
|
|
|
|
test('expanded segment columns prioritize upload state and attachments', () => {
|
|
const template = parse(
|
|
readFileSync(
|
|
new URL('../src/views/pl/legal-use-document/StopDocuments.vue', import.meta.url),
|
|
'utf8'
|
|
)
|
|
).descriptor.template!.content
|
|
const table = template.slice(template.indexOf('<el-table\n'), template.indexOf('</el-table>'))
|
|
assert.deepEqual(
|
|
[...table.matchAll(/<el-table-column\b[^>]*\blabel="([^"]+)"/g)].map((match) => match[1]),
|
|
[
|
|
'运输段',
|
|
'产品',
|
|
'上传情况',
|
|
'附件',
|
|
'合同编号',
|
|
'本段主体',
|
|
'规格',
|
|
'单位',
|
|
'本段位置',
|
|
'上传时间',
|
|
'操作'
|
|
]
|
|
)
|
|
})
|