342 lines
13 KiB
JavaScript
342 lines
13 KiB
JavaScript
import assert from 'node:assert/strict'
|
|
import test from 'node:test'
|
|
import { readFileSync } from 'node:fs'
|
|
import ts from 'typescript'
|
|
import { parse, compileScript, compileTemplate } from '@vue/compiler-sfc'
|
|
import * as vue from 'vue'
|
|
|
|
const read = (path) => readFileSync(path, 'utf8')
|
|
function evaluate(source, imports = {}) {
|
|
const code = ts.transpileModule(source, {
|
|
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }
|
|
}).outputText
|
|
const module = { exports: {} }
|
|
new Function('require', 'module', 'exports', code)(
|
|
(name) => {
|
|
if (name.endsWith('.vue')) return { default: {} }
|
|
assert.ok(imports[name], `Unexpected import ${name}`)
|
|
return imports[name]
|
|
},
|
|
module,
|
|
module.exports
|
|
)
|
|
return module.exports
|
|
}
|
|
const format = evaluate(read('src/utils/business/format.ts'))
|
|
const gateway = evaluate(read('src/utils/business/gateway.ts'), { './format': format })
|
|
const polling = evaluate(read('src/utils/business/pollingScheduler.ts'), { './gateway': gateway })
|
|
const response = (data) => ({ code: 0, data })
|
|
const deferred = () => {
|
|
let resolve, reject
|
|
const promise = new Promise((a, b) => {
|
|
resolve = a
|
|
reject = b
|
|
})
|
|
return { promise, resolve, reject }
|
|
}
|
|
const tick = async () => {
|
|
await vue.nextTick()
|
|
await Promise.resolve()
|
|
await vue.nextTick()
|
|
}
|
|
|
|
function serviceHarness(overrides = {}) {
|
|
const calls = []
|
|
const service = new Proxy(
|
|
{},
|
|
{
|
|
get:
|
|
(_, method) =>
|
|
async (...args) => {
|
|
calls.push({ method, args })
|
|
return overrides[method] ? overrides[method](...args) : response({ items: [], total: 0 })
|
|
}
|
|
}
|
|
)
|
|
const modules = {
|
|
'./packageManage': { PackageManageService: service },
|
|
'./packageSeries': { PackageSeriesService: service },
|
|
'./shop': { ShopService: service },
|
|
'./card': { CardService: service },
|
|
'./device': { DeviceService: service },
|
|
'./account': { AccountService: service },
|
|
'./packageSilence': { PackageSilenceService: service },
|
|
'@/utils/business/gateway': gateway
|
|
}
|
|
return { ...evaluate(read('src/api/modules/businessOptions.ts'), modules), calls }
|
|
}
|
|
test('entity search uses documented keyword/name fields and returns labels, never database IDs as labels', async () => {
|
|
const h = serviceHarness({
|
|
getPackages: () => response({ items: [{ id: 8, package_name: '实名套餐' }], total: 41 }),
|
|
getPackageSeries: () => response({ items: [{ id: 9, series_name: '流量系列' }], total: 1 }),
|
|
getShops: () => response({ items: [{ id: 10, shop_name: '一号代理' }], total: 1 }),
|
|
getAccounts: () =>
|
|
response({ items: [{ id: 11, username: '张三', shop_name: '一号代理' }], total: 1 }),
|
|
getStandaloneIotCards: () =>
|
|
response({ items: [{ id: 12, iccid: '89860001', virtual_no: 'V-01' }], total: 1 }),
|
|
getDevices: () =>
|
|
response({ items: [{ id: 13, virtual_no: 'D-01', device_name: '设备甲' }], total: 1 })
|
|
})
|
|
for (const [kind, method, field] of [
|
|
['package', 'getPackages', 'package_name'],
|
|
['series', 'getPackageSeries', 'series_name'],
|
|
['shop', 'getShops', 'shop_name'],
|
|
['account', 'getAccounts', 'username'],
|
|
['card', 'getStandaloneIotCards', 'keyword']
|
|
]) {
|
|
const result = await h.searchBusinessOptions(kind, ' 关键字 ', 2)
|
|
assert.ok(result.options.length)
|
|
assert.equal(typeof result.options[0].value, 'number')
|
|
assert.notEqual(result.options[0].label, String(result.options[0].value))
|
|
const call = h.calls.at(-1)
|
|
assert.equal(call.method, method)
|
|
assert.deepEqual(call.args[0], { page: 2, page_size: 20, [field]: '关键字' })
|
|
assert.equal(result.hasMore, kind === 'package')
|
|
}
|
|
const assets = await h.searchBusinessOptions('asset', '01')
|
|
assert.deepEqual(
|
|
assets.options.map((x) => x.value),
|
|
['89860001', 'D-01']
|
|
)
|
|
assert.match(assets.options[0].label, /V-01/)
|
|
assert.match(assets.options[1].label, /设备甲/)
|
|
})
|
|
test('package record selection uses real usage IDs with asset/package context and readable order labels', async () => {
|
|
const h = serviceHarness({
|
|
usages: () =>
|
|
response({
|
|
items: [
|
|
{
|
|
package_usage_id: 99,
|
|
package_name: '实名套餐',
|
|
asset_identifier: '8986',
|
|
order_no: 'ORD-01'
|
|
}
|
|
],
|
|
total: 1
|
|
})
|
|
})
|
|
const result = await h.searchBusinessOptions('usage', '', 1, {
|
|
asset_identifier: '8986',
|
|
package_id: 8
|
|
})
|
|
assert.deepEqual(h.calls[0].args[0], {
|
|
page: 1,
|
|
page_size: 20,
|
|
view: 'all',
|
|
asset_identifier: '8986',
|
|
package_id: 8
|
|
})
|
|
assert.deepEqual(result.options, [{ value: 99, label: '实名套餐 · 8986 · ORD-01' }])
|
|
await h.searchBusinessOptions('usage', '8987', 1, { asset_identifier: '8986' })
|
|
assert.equal(h.calls.at(-1).args[0].asset_identifier, '8987')
|
|
})
|
|
test('asset lookup keeps permitted card results when the device lookup fails and reports the partial failure', async () => {
|
|
const h = serviceHarness({
|
|
getStandaloneIotCards: () => response({ items: [{ id: 12, iccid: '89860001' }], total: 1 }),
|
|
getDevices: () => ({ code: 403, msg: '无设备查看权限' })
|
|
})
|
|
const result = await h.searchBusinessOptions('asset', '')
|
|
assert.equal(result.options[0].value, '89860001')
|
|
assert.match(result.warning, /设备.*无设备查看权限/)
|
|
})
|
|
test('policy selection resolves existing package/series names through detail endpoints and propagates business errors', async () => {
|
|
const h = serviceHarness({
|
|
getPackageDetail: () => response({ package_name: '已有套餐' }),
|
|
getPackageSeriesDetail: () => response({ series_name: '已有系列' }),
|
|
getShops: () => ({ code: 403, msg: '无数据权限' })
|
|
})
|
|
assert.deepEqual(await h.resolveBusinessOption('package', 8), { value: 8, label: '已有套餐' })
|
|
assert.deepEqual(await h.resolveBusinessOption('series', 9), { value: 9, label: '已有系列' })
|
|
await assert.rejects(h.searchBusinessOptions('shop', ''), /无数据权限/)
|
|
})
|
|
|
|
function componentHarness({ entity = 'shop', modelValue, search, resolve, allowed = true } = {}) {
|
|
const hooks = [],
|
|
calls = [],
|
|
events = []
|
|
const user = vue.reactive({ info: { id: 1, user_type: 2, shop_id: 1 }, isLogin: true })
|
|
const props = vue.reactive({ entity, modelValue, allowed, clearable: true })
|
|
const runtime = {
|
|
...vue,
|
|
onBeforeUnmount: (fn) => hooks.push(fn),
|
|
onDeactivated: (fn) => hooks.push(fn),
|
|
onActivated() {}
|
|
}
|
|
const { descriptor } = parse(read('src/components/business/BusinessEntitySelect.vue'))
|
|
const component = evaluate(compileScript(descriptor, { id: 'entity-select' }).content, {
|
|
vue: runtime,
|
|
'@/store/modules/user': { useUserStore: () => user },
|
|
'@/utils/business/gateway': gateway,
|
|
'@/utils/business/pollingScheduler': polling,
|
|
'@/api/modules/businessOptions': {
|
|
BUSINESS_OPTION_LABELS: {
|
|
shop: '店铺',
|
|
package: '套餐',
|
|
series: '系列',
|
|
asset: '资产',
|
|
account: '操作人'
|
|
},
|
|
searchBusinessOptions: async (...args) => {
|
|
calls.push(args)
|
|
return search ? search(...args) : { options: [], hasMore: false }
|
|
},
|
|
resolveBusinessOption: resolve || (async (_, value) => ({ value, label: '已有名称' }))
|
|
}
|
|
}).default
|
|
const scope = vue.effectScope()
|
|
const state = scope.run(() =>
|
|
component.setup(props, {
|
|
expose() {},
|
|
emit: (event, value) => {
|
|
events.push([event, value])
|
|
props.modelValue = value
|
|
}
|
|
})
|
|
)
|
|
return {
|
|
state,
|
|
props,
|
|
user,
|
|
calls,
|
|
events,
|
|
dispose: () => {
|
|
hooks.forEach((fn) => fn())
|
|
scope.stop()
|
|
}
|
|
}
|
|
}
|
|
test('rapid keywords ignore late responses, selection retains its label through later searches and clear emits no ID', async () => {
|
|
const old = deferred()
|
|
const h = componentHarness({
|
|
search: (_, keyword) =>
|
|
keyword === 'old'
|
|
? old.promise
|
|
: {
|
|
options: [
|
|
{ value: keyword === 'new' ? 2 : 3, label: keyword === 'new' ? '新店铺' : '其他店铺' }
|
|
],
|
|
hasMore: false
|
|
}
|
|
})
|
|
const pending = h.state.search('old')
|
|
await h.state.search('new')
|
|
old.resolve({ options: [{ value: 1, label: '旧店铺' }], hasMore: false })
|
|
await pending
|
|
assert.deepEqual(h.state.options.value, [{ value: 2, label: '新店铺' }])
|
|
h.state.selection.value = 2
|
|
await h.state.search('other')
|
|
assert.deepEqual(
|
|
h.state.options.value.map((x) => x.label),
|
|
['新店铺', '其他店铺']
|
|
)
|
|
assert.deepEqual(h.events[0], ['update:modelValue', 2])
|
|
h.state.selection.value = undefined
|
|
assert.deepEqual(
|
|
h.state.options.value.map((x) => x.label),
|
|
['其他店铺']
|
|
)
|
|
h.dispose()
|
|
})
|
|
test('load more appends options and request failures are retryable rather than fabricated empty results', async () => {
|
|
let fail = true
|
|
const h = componentHarness({
|
|
search: (_, keyword, page) => {
|
|
if (keyword === 'fail' && fail) throw new Error('网络失败')
|
|
return { options: [{ value: page, label: `店铺${page}` }], hasMore: page < 2 }
|
|
}
|
|
})
|
|
await h.state.search('')
|
|
assert.equal(h.state.hasMore.value, true)
|
|
await h.state.load(2)
|
|
assert.deepEqual(
|
|
h.state.options.value.map((x) => x.value),
|
|
[1, 2]
|
|
)
|
|
assert.equal(h.state.hasMore.value, false)
|
|
await h.state.search('fail')
|
|
assert.equal(h.state.error.value, '网络失败')
|
|
fail = false
|
|
await h.state.load(1)
|
|
assert.equal(h.state.error.value, '')
|
|
assert.equal(h.calls.at(-1)[1], 'fail')
|
|
h.dispose()
|
|
})
|
|
test('permission/account/context changes and unmount discard in-flight results without new unauthorized requests', async () => {
|
|
for (const action of ['permission', 'account', 'context', 'unmount']) {
|
|
const pending = deferred(),
|
|
h = componentHarness({ search: () => pending.promise })
|
|
const loading = h.state.search('a')
|
|
if (action === 'permission') h.props.allowed = false
|
|
if (action === 'account') h.user.info.id = 2
|
|
if (action === 'context') h.props.context = { package_id: 8 }
|
|
if (action === 'unmount') h.dispose()
|
|
await tick()
|
|
pending.resolve({ options: [{ value: 8, label: '已失效数据' }], hasMore: true })
|
|
await loading
|
|
assert.deepEqual(h.state.options.value, [], action)
|
|
assert.equal(h.state.loading.value, false)
|
|
if (action === 'permission' || action === 'unmount') {
|
|
await h.state.search('b')
|
|
assert.equal(h.calls.length, 1)
|
|
}
|
|
if (action !== 'unmount') h.dispose()
|
|
}
|
|
})
|
|
test('existing multi-selection hydrates readable labels and system operator preserves numeric zero', async () => {
|
|
const h = componentHarness({
|
|
entity: 'package',
|
|
modelValue: [7, 8],
|
|
resolve: async (_, value) => ({ value, label: `套餐名${value}` })
|
|
})
|
|
await tick()
|
|
assert.deepEqual(
|
|
h.state.options.value.map((x) => x.label),
|
|
['套餐名7', '套餐名8']
|
|
)
|
|
h.props.modelValue = [8]
|
|
await tick()
|
|
assert.deepEqual(
|
|
h.state.options.value.map((x) => x.label),
|
|
['套餐名8']
|
|
)
|
|
h.dispose()
|
|
const account = componentHarness({ entity: 'account' })
|
|
account.props.includeSystem = true
|
|
account.state.selection.value = 0
|
|
assert.deepEqual(account.events.at(-1), ['update:modelValue', 0])
|
|
assert.deepEqual(account.state.options.value, [{ value: 0, label: '系统' }])
|
|
account.dispose()
|
|
})
|
|
test('all affected templates compile and raw-ID search controls are replaced throughout the shared pages', () => {
|
|
const files = [
|
|
'src/components/business/BusinessEntitySelect.vue',
|
|
'src/components/core/forms/art-search-bar/widget/art-search-business-select/index.vue',
|
|
'src/components/core/forms/art-search-bar/index.vue',
|
|
'src/components/business/package-silence/SilenceManagement.vue',
|
|
'src/components/business/package-silence/SilencePolicyDialog.vue',
|
|
'src/views/order-management/order-list/index.vue',
|
|
'src/views/finance/refund/index.vue',
|
|
'src/views/polling-management/priority-queue/index.vue',
|
|
'src/views/polling-management/polling-pauses/index.vue',
|
|
'src/views/asset-management/phone-asset-association/index.vue',
|
|
'src/views/asset-management/record-management/asset-assign/index.vue'
|
|
]
|
|
for (const file of files) {
|
|
const { descriptor, errors } = parse(read(file))
|
|
assert.deepEqual(errors, [], file)
|
|
assert.deepEqual(
|
|
compileTemplate({ source: descriptor.template.content, filename: file, id: file }).errors,
|
|
[],
|
|
file
|
|
)
|
|
}
|
|
const management = read(files[3])
|
|
assert.match(read(files[2]), /v-show="index < visibleItemCount"/)
|
|
for (const field of ['asset_identifier', 'package_id', 'shop_id', 'package_usage_id'])
|
|
assert.match(management, new RegExp(`entity\\('${field}'`))
|
|
assert.doesNotMatch(management, /请输入套餐 ID|number\('(?:package_id|shop_id|package_usage_id)'/)
|
|
assert.doesNotMatch(read(files[4]), /label: `ID/)
|
|
assert.doesNotMatch(read(files[7]), /请输入卡ID/)
|
|
})
|