489 lines
18 KiB
TypeScript
489 lines
18 KiB
TypeScript
import assert from 'node:assert/strict'
|
|
import { existsSync, readFileSync } from 'node:fs'
|
|
import test from 'node:test'
|
|
import * as vue from 'vue'
|
|
import { parse } from '@vue/compiler-sfc'
|
|
import { renderToString } from 'vue/server-renderer'
|
|
import { syncViewRowFromFlatList } from '../src/utils/view-dialog-refresh'
|
|
import ts from 'typescript'
|
|
import { getViewKvSpanClass } from '../src/utils/view-field-layout'
|
|
|
|
function deferred<T>() {
|
|
let resolve!: (value: T) => void
|
|
let reject!: (reason: unknown) => void
|
|
const promise = new Promise<T>((yes, no) => {
|
|
resolve = yes
|
|
reject = no
|
|
})
|
|
return { promise, resolve, reject }
|
|
}
|
|
|
|
async function flush() {
|
|
await Promise.resolve()
|
|
await vue.nextTick()
|
|
}
|
|
|
|
function loadComposable(lookup: (plate: string) => Promise<unknown>) {
|
|
const path = new URL('../src/composables/useVehicleFuelCard.ts', import.meta.url)
|
|
assert.ok(existsSync(path), 'fuel-card display composable must exist')
|
|
const source = readFileSync(path, 'utf8')
|
|
const ast = ts.createSourceFile('composable.ts', source, ts.ScriptTarget.Latest, true)
|
|
const code = ast.statements
|
|
.filter((node) => !ts.isImportDeclaration(node))
|
|
.map((node) => node.getText(ast))
|
|
.join('\n')
|
|
const compiled = ts.transpileModule(code, {
|
|
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }
|
|
}).outputText
|
|
const bindings = {
|
|
ref: vue.ref,
|
|
computed: vue.computed,
|
|
watch: vue.watch,
|
|
onScopeDispose: vue.onScopeDispose,
|
|
fetchVehicleFuelCard: lookup,
|
|
exports: {}
|
|
}
|
|
return new Function(...Object.keys(bindings), `${compiled}; return exports.useVehicleFuelCard`)(
|
|
...Object.values(bindings)
|
|
)
|
|
}
|
|
|
|
function mount(initial = { visible: true, rechargeType: 'fuel', licensePlate: '' }) {
|
|
const state = vue.reactive(initial)
|
|
const reads: Array<ReturnType<typeof deferred<any>> & { plate: string }> = []
|
|
const useVehicleFuelCard = loadComposable((plate) => {
|
|
const request = { ...deferred<any>(), plate }
|
|
reads.push(request)
|
|
return request.promise
|
|
})
|
|
const scope = vue.effectScope()
|
|
const card = scope.run(() =>
|
|
useVehicleFuelCard({
|
|
visible: () => state.visible,
|
|
rechargeType: () => state.rechargeType,
|
|
licensePlate: () => state.licensePlate
|
|
})
|
|
)
|
|
return { state, reads, card, stop: () => scope.stop() }
|
|
}
|
|
|
|
test('only fuel shows a card; empty plate asks for selection without a request', () => {
|
|
const h = mount()
|
|
assert.equal(h.card.show.value, true)
|
|
assert.equal(h.card.text.value, '请先选择车牌号')
|
|
assert.equal(h.reads.length, 0)
|
|
for (const type of ['etc', 'shanxia']) {
|
|
h.state.rechargeType = type
|
|
h.state.licensePlate = '皖LB1283'
|
|
assert.equal(h.card.show.value, false)
|
|
assert.equal(h.card.text.value, '')
|
|
assert.equal(h.reads.length, 0)
|
|
}
|
|
h.stop()
|
|
})
|
|
|
|
test('loading, full string card, missing binding and failure are distinct', async () => {
|
|
const h = mount({ visible: true, rechargeType: 'fuel', licensePlate: ' 皖lb1283 ' })
|
|
assert.equal(h.reads[0].plate, '皖LB1283')
|
|
assert.equal(h.card.text.value, '加载中…')
|
|
h.reads[0].resolve({ licensePlate: '皖LB1283', cardNo: '1000113400009309765' })
|
|
await flush()
|
|
assert.equal(h.card.text.value, '1000113400009309765')
|
|
h.state.licensePlate = '皖H21522'
|
|
assert.equal(h.card.text.value, '加载中…')
|
|
h.reads[1].resolve({ licensePlate: '皖H21522', cardNo: null })
|
|
await flush()
|
|
assert.equal(h.card.text.value, '未绑定油卡')
|
|
h.state.licensePlate = '皖H09177'
|
|
h.reads[2].reject(new Error('network unavailable'))
|
|
await flush()
|
|
assert.equal(h.card.text.value, '油卡信息加载失败')
|
|
assert.equal(h.card.cardNo.value, null)
|
|
h.stop()
|
|
})
|
|
|
|
for (const rejected of [false, true]) {
|
|
test(`plate A/B/A switch ignores old ${rejected ? 'error' : 'success'} and finally`, async () => {
|
|
const h = mount({ visible: true, rechargeType: 'fuel', licensePlate: '皖LB1283' })
|
|
h.state.licensePlate = '皖H21522'
|
|
h.state.licensePlate = '皖LB1283'
|
|
if (rejected) h.reads[0].reject(new Error('stale'))
|
|
else h.reads[0].resolve({ licensePlate: '皖LB1283', cardNo: 'old-card' })
|
|
await flush()
|
|
assert.equal(h.card.text.value, '加载中…')
|
|
h.reads[2].resolve({ licensePlate: '皖LB1283', cardNo: '1000113400009309765' })
|
|
await flush()
|
|
h.reads[1].resolve({ licensePlate: '皖H21522', cardNo: '1000113400008366476' })
|
|
await flush()
|
|
assert.equal(h.card.text.value, '1000113400009309765')
|
|
h.stop()
|
|
})
|
|
}
|
|
|
|
for (const action of ['close', 'type', 'clear', 'unmount']) {
|
|
test(`${action} clears immediately and ignores pending lookup`, async () => {
|
|
const h = mount({ visible: true, rechargeType: 'fuel', licensePlate: '皖LB1283' })
|
|
if (action === 'close') h.state.visible = false
|
|
if (action === 'type') h.state.rechargeType = 'shanxia'
|
|
if (action === 'clear') h.state.licensePlate = ''
|
|
if (action === 'unmount') h.stop()
|
|
assert.equal(h.card.cardNo.value, null)
|
|
h.reads[0].resolve({ licensePlate: '皖LB1283', cardNo: '1000113400009309765' })
|
|
await flush()
|
|
assert.equal(h.card.cardNo.value, null)
|
|
if (action === 'clear') assert.equal(h.card.text.value, '请先选择车牌号')
|
|
h.stop()
|
|
})
|
|
}
|
|
|
|
test('reopening the same detail queries a fresh binding and invalidates earlier request', async () => {
|
|
const h = mount({ visible: true, rechargeType: 'fuel', licensePlate: '皖LB1283' })
|
|
h.state.visible = false
|
|
h.state.visible = true
|
|
assert.equal(h.reads.length, 2)
|
|
h.reads[0].resolve({ licensePlate: '皖LB1283', cardNo: '1000113400009253456' })
|
|
await flush()
|
|
assert.equal(h.card.text.value, '加载中…')
|
|
h.reads[1].resolve({ licensePlate: '皖LB1283', cardNo: '1000113400009309765' })
|
|
await flush()
|
|
assert.equal(h.card.text.value, '1000113400009309765')
|
|
h.stop()
|
|
})
|
|
|
|
test('separate form and detail contexts never share the selected plate', async () => {
|
|
const form = mount({ visible: true, rechargeType: 'fuel', licensePlate: '皖LB1283' })
|
|
const detail = mount({ visible: true, rechargeType: 'fuel', licensePlate: '皖H21522' })
|
|
form.reads[0].resolve({ licensePlate: '皖LB1283', cardNo: '1000113400009309765' })
|
|
detail.reads[0].resolve({ licensePlate: '皖H21522', cardNo: '1000113400008366476' })
|
|
await flush()
|
|
assert.equal(form.card.text.value, '1000113400009309765')
|
|
assert.equal(detail.card.text.value, '1000113400008366476')
|
|
form.stop()
|
|
detail.stop()
|
|
})
|
|
|
|
type Page = 'apply' | 'approval' | 'record'
|
|
|
|
function mountPage(page: Page) {
|
|
const source = readFileSync(
|
|
new URL(`../src/views/pl/vehicle-recharge-${page}/index.vue`, import.meta.url),
|
|
'utf8'
|
|
)
|
|
const descriptor = parse(source).descriptor
|
|
const ast = ts.createSourceFile(
|
|
'page.ts',
|
|
descriptor.scriptSetup!.content,
|
|
ts.ScriptTarget.Latest,
|
|
true
|
|
)
|
|
const names =
|
|
page === 'approval'
|
|
? [
|
|
'viewVisible',
|
|
'viewDetail',
|
|
'viewFuelCard',
|
|
'viewRequestId',
|
|
'handleVisible',
|
|
'handleDetail',
|
|
'handleFuelCard',
|
|
'handleRequestId',
|
|
'opinion',
|
|
'disburseForm',
|
|
'resetOpinionForDetail',
|
|
'isDisburseNode',
|
|
'viewSections',
|
|
'openView',
|
|
'openHandleDialog'
|
|
]
|
|
: [
|
|
'dialogVisible',
|
|
'formData',
|
|
'formFuelCard',
|
|
'viewVisible',
|
|
'viewLoading',
|
|
'viewingRecord',
|
|
'viewFuelCard',
|
|
'viewRequestId',
|
|
'viewSections',
|
|
'handleView',
|
|
...(page === 'apply'
|
|
? ['viewDetail', 'refreshViewIfOpen']
|
|
: ['viewApplyDetail', 'loadViewApplyDetail', 'dialogMode'])
|
|
]
|
|
const declarations = names
|
|
.map((name) => {
|
|
const node = ast.statements.find(
|
|
(statement) =>
|
|
(ts.isFunctionDeclaration(statement) && statement.name?.text === name) ||
|
|
(ts.isVariableStatement(statement) &&
|
|
statement.declarationList.declarations.some((d) => d.name.getText(ast) === name))
|
|
)
|
|
assert.ok(node, `${page} must provide ${name}`)
|
|
return node!
|
|
})
|
|
.sort((a, b) => a.pos - b.pos)
|
|
.map((node) => node.getText(ast))
|
|
const watches = ast.statements.filter(
|
|
(node) =>
|
|
ts.isExpressionStatement(node) &&
|
|
ts.isCallExpression(node.expression) &&
|
|
node.expression.expression.getText(ast) === 'watch' &&
|
|
['viewVisible', 'handleVisible'].includes(node.expression.arguments[0].getText(ast))
|
|
)
|
|
const reads: Array<ReturnType<typeof deferred<any>> & { plate: string }> = []
|
|
const details: Array<ReturnType<typeof deferred<any>> & { id: string }> = []
|
|
const bindings = {
|
|
ref: vue.ref,
|
|
reactive: vue.reactive,
|
|
computed: vue.computed,
|
|
watch: vue.watch,
|
|
tableData: vue.ref([]),
|
|
syncViewRowFromFlatList,
|
|
useVehicleFuelCard: loadComposable((plate) => {
|
|
const request = { ...deferred<any>(), plate }
|
|
reads.push(request)
|
|
return request.promise
|
|
}),
|
|
vehicleRechargeApplyApi: {
|
|
get: (id: string) => {
|
|
const request = { ...deferred<any>(), id }
|
|
details.push(request)
|
|
return request.promise
|
|
}
|
|
},
|
|
vehicleTrailerLookup: vue.ref({}),
|
|
formatPlVehiclePlateDisplay: (plate: string) => plate,
|
|
getPlRechargeTypeLabel: (type: string) => type,
|
|
getPlRechargeTypeTagType: () => 'success',
|
|
getPlRechargeApplyStatusLabel: (status: string) => status,
|
|
getPlRechargeApplyStatusTagType: () => 'info',
|
|
DEFAULT_APPROVAL_OPINION: '同意'
|
|
}
|
|
const compiled = ts.transpileModule(
|
|
declarations.join('\n') + '\n' + watches.map((node) => node.getText(ast)).join('\n'),
|
|
{ compilerOptions: { target: ts.ScriptTarget.ES2022 } }
|
|
).outputText
|
|
const scope = vue.effectScope()
|
|
const api = scope.run(() =>
|
|
new Function(...Object.keys(bindings), `${compiled}; return {${names.join(',')}}`)(
|
|
...Object.values(bindings)
|
|
)
|
|
)
|
|
const snippet = descriptor.template!.content.match(
|
|
/<el-form-item\b[^>]*label="绑定油卡卡号"[^>]*>[\s\S]*?<\/el-form-item>/
|
|
)?.[0]
|
|
assert.ok(snippet, `${page} form must show the current binding separately`)
|
|
const render = vue.compile(snippet, { isCustomElement: () => true })
|
|
const markup = () => {
|
|
const context = vue.proxyRefs(api)
|
|
return renderToString(vue.createSSRApp({ render: () => render.call(context, context, []) }))
|
|
}
|
|
return { api, reads, details, markup, stop: () => scope.stop() }
|
|
}
|
|
|
|
function rechargeRow(id: string, plate: string, rechargeType = 'fuel') {
|
|
return {
|
|
id,
|
|
applyId: id,
|
|
licensePlate: plate,
|
|
rechargeType,
|
|
recordId: `record-${id}`,
|
|
cardNo: '1000113400009253456',
|
|
record: { cardNo: '1000113400009253456' }
|
|
}
|
|
}
|
|
|
|
for (const page of ['apply', 'approval', 'record'] as const) {
|
|
test(`${page} actual form renders fuel binding with full card string and preserves entered card`, async () => {
|
|
const h = mountPage(page)
|
|
const form = page === 'approval' ? h.api.disburseForm : h.api.formData
|
|
const row = rechargeRow('A', '皖LB1283')
|
|
if (page === 'approval') {
|
|
h.api.handleDetail.value = row
|
|
h.api.handleVisible.value = true
|
|
} else {
|
|
Object.assign(form, row)
|
|
h.api.dialogVisible.value = true
|
|
}
|
|
form.cardNo = '1000113400009253456'
|
|
assert.match(await h.markup(), /加载中/)
|
|
h.reads.at(-1)!.resolve({ licensePlate: '皖LB1283', cardNo: '1000113400009309765' })
|
|
await flush()
|
|
assert.match(await h.markup(), /1000113400009309765/)
|
|
assert.equal(form.cardNo, '1000113400009253456')
|
|
if (page === 'approval') h.api.handleDetail.value.rechargeType = 'shanxia'
|
|
else form.rechargeType = 'shanxia'
|
|
assert.doesNotMatch(await h.markup(), /绑定油卡卡号|1000113400009309765/)
|
|
h.stop()
|
|
})
|
|
|
|
test(`${page} detail switches rows immediately and rejects stale detail/card responses`, async () => {
|
|
const h = mountPage(page)
|
|
const open = page === 'approval' ? h.api.openView : h.api.handleView
|
|
const a = rechargeRow('A', '皖LB1283')
|
|
const b = rechargeRow('B', '皖H21522')
|
|
const first = open(a)
|
|
assert.equal(h.reads.at(-1)?.plate, a.licensePlate)
|
|
const second = open(b)
|
|
assert.equal(h.reads.at(-1)?.plate, b.licensePlate)
|
|
h.details[1].resolve({ data: b })
|
|
await second
|
|
h.reads.at(-1)!.resolve({ licensePlate: b.licensePlate, cardNo: '1000113400008366476' })
|
|
await flush()
|
|
h.details[0].resolve({ data: a })
|
|
h.reads[0].resolve({ licensePlate: a.licensePlate, cardNo: '1000113400009309765' })
|
|
await first
|
|
await flush()
|
|
const fields = h.api.viewSections.value.flatMap((section: any) => section.fields)
|
|
assert.equal(fields.find((field: any) => field.label === '车牌号')?.value, b.licensePlate)
|
|
assert.equal(
|
|
fields.find((field: any) => field.label === '绑定油卡卡号')?.value,
|
|
'1000113400008366476'
|
|
)
|
|
assert.equal(fields.find((field: any) => field.label === '卡号')?.value, '1000113400009253456')
|
|
for (const field of fields.filter((field: any) =>
|
|
['绑定油卡卡号', '卡号'].includes(field.label)
|
|
)) {
|
|
assert.equal(getViewKvSpanClass(field, 4, String), '', '卡号只占一个字段位置')
|
|
}
|
|
h.stop()
|
|
})
|
|
|
|
test(`${page} close/reopen same detail ignores pending detail response`, async () => {
|
|
const h = mountPage(page)
|
|
const open = page === 'approval' ? h.api.openView : h.api.handleView
|
|
const a = rechargeRow('A', '皖LB1283')
|
|
const first = open(a)
|
|
h.api.viewVisible.value = false
|
|
const second = open(a)
|
|
h.details[1].resolve({ data: a })
|
|
await second
|
|
h.reads.at(-1)!.resolve({ licensePlate: a.licensePlate, cardNo: '1000113400009309765' })
|
|
await flush()
|
|
h.details[0].resolve({ data: { ...a, licensePlate: '皖H21522' } })
|
|
await first
|
|
await flush()
|
|
const fields = h.api.viewSections.value.flatMap((section: any) => section.fields)
|
|
assert.equal(fields.find((field: any) => field.label === '车牌号')?.value, a.licensePlate)
|
|
assert.equal(h.api.viewFuelCard.text.value, '1000113400009309765')
|
|
h.stop()
|
|
})
|
|
}
|
|
|
|
function lookupWithResponse(response: unknown) {
|
|
const source = readFileSync(
|
|
new URL('../src/api/pl/vehicle-fuel-card-lookup.ts', import.meta.url),
|
|
'utf8'
|
|
)
|
|
const ast = ts.createSourceFile('lookup.ts', source, ts.ScriptTarget.Latest, true)
|
|
const code = ast.statements
|
|
.filter((node) => !ts.isImportDeclaration(node))
|
|
.map((node) => node.getText(ast))
|
|
.join('\n')
|
|
const bxSource = readFileSync(new URL('../src/utils/http/bx.ts', import.meta.url), 'utf8')
|
|
const bxAst = ts.createSourceFile('bx.ts', bxSource, ts.ScriptTarget.Latest, true)
|
|
const pickCode = bxAst.statements
|
|
.find((node) => ts.isFunctionDeclaration(node) && node.name?.text === 'pickBxData')!
|
|
.getText(bxAst)
|
|
const compiled = ts.transpileModule(`${pickCode}\n${code}`, {
|
|
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }
|
|
}).outputText
|
|
const calls: unknown[] = []
|
|
const request = {
|
|
get: async (...args: unknown[]) => {
|
|
calls.push(args)
|
|
return response
|
|
}
|
|
}
|
|
const lookup = new Function(
|
|
'request',
|
|
'exports',
|
|
`${compiled}; return exports.fetchVehicleFuelCard`
|
|
)(request, {})
|
|
return { lookup, calls }
|
|
}
|
|
|
|
for (const cardNo of [null, '1000113400009309765']) {
|
|
for (const wrapped of [false, true]) {
|
|
test(`lookup accepts ${wrapped ? 'envelope' : 'raw JSON'} and ${cardNo === null ? 'no binding' : 'string card'}`, async () => {
|
|
const result = { licensePlate: '皖LB1283', cardNo }
|
|
const h = lookupWithResponse(wrapped ? { code: 0, data: result } : result)
|
|
assert.deepEqual(await h.lookup('皖LB1283'), result)
|
|
assert.deepEqual(h.calls, [
|
|
['/transport/vehicle-recharge-apply/fuel-card', { params: { licensePlate: '皖LB1283' } }]
|
|
])
|
|
})
|
|
}
|
|
}
|
|
|
|
for (const response of [
|
|
null,
|
|
{},
|
|
{ licensePlate: '皖LB1283' },
|
|
{ licensePlate: '皖LB1283', cardNo: Number('1000113400009309765') },
|
|
{ licensePlate: '皖H21522', cardNo: '1000113400008366476' }
|
|
]) {
|
|
test(`invalid lookup response is an error: ${JSON.stringify(response)}`, async () => {
|
|
const h = lookupWithResponse(response)
|
|
await assert.rejects(() => h.lookup('皖LB1283'), /响应无效/)
|
|
})
|
|
}
|
|
|
|
test('approval handling switches or closes without adopting a stale detail plate', async () => {
|
|
const h = mountPage('approval')
|
|
const a = rechargeRow('A', '皖LB1283')
|
|
const b = rechargeRow('B', '皖H21522')
|
|
const first = h.api.openHandleDialog(a)
|
|
h.api.handleVisible.value = false
|
|
const second = h.api.openHandleDialog(b)
|
|
h.details[1].resolve({ data: b })
|
|
await second
|
|
h.reads.at(-1)!.resolve({ licensePlate: b.licensePlate, cardNo: '1000113400008366476' })
|
|
await flush()
|
|
h.details[0].resolve({ data: a })
|
|
await first
|
|
await flush()
|
|
assert.equal(h.api.handleDetail.value.licensePlate, b.licensePlate)
|
|
assert.match(await h.markup(), /1000113400008366476/)
|
|
h.stop()
|
|
})
|
|
|
|
for (const refreshFails of [false, true]) {
|
|
for (const refreshFirst of [false, true]) {
|
|
test(`apply overlapping view refresh releases loading: fails=${refreshFails}, refreshFirst=${refreshFirst}`, async () => {
|
|
const h = mountPage('apply')
|
|
const row = rechargeRow('A', '皖LB1283')
|
|
const first = h.api.handleView(row)
|
|
const refresh = h.api.refreshViewIfOpen(row.id).catch((error: unknown) => error)
|
|
assert.equal(h.details.length, 2)
|
|
assert.equal(h.api.viewLoading.value, true)
|
|
const finishInitial = async () => {
|
|
h.details[0].resolve({ data: { ...row, remark: 'stale' } })
|
|
await first
|
|
}
|
|
const finishRefresh = async () => {
|
|
if (refreshFails) h.details[1].reject(new Error('refresh unavailable'))
|
|
else h.details[1].resolve({ data: { ...row, remark: 'fresh' } })
|
|
await refresh
|
|
}
|
|
if (refreshFirst) {
|
|
await finishRefresh()
|
|
assert.equal(h.api.viewLoading.value, false)
|
|
await finishInitial()
|
|
} else {
|
|
await finishInitial()
|
|
assert.equal(
|
|
h.api.viewLoading.value,
|
|
true,
|
|
'stale initial read cannot finish active refresh'
|
|
)
|
|
await finishRefresh()
|
|
}
|
|
assert.equal(h.api.viewLoading.value, false)
|
|
assert.notEqual(h.api.viewDetail.value.remark, 'stale')
|
|
if (!refreshFails) assert.equal(h.api.viewDetail.value.remark, 'fresh')
|
|
h.stop()
|
|
})
|
|
}
|
|
}
|