Files
shanxia2.0/web-admin/scripts/vehicle-fuel-card-lookup.test.ts

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()
})
}
}