feat(pl): 按运输段产品维护合法使用证明并隔离异步会话。
This commit is contained in:
1 parent
722076fb3f
commit
4cb2d88dab
8 files changed
+1103
-404
No files matched your search
@@ -0,0 +1,511 @@
|
||||
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 }`
|
||||
)(...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()
|
||||
})
|
||||
Reference in new issue
Block a user