557 lines
19 KiB
JavaScript
557 lines
19 KiB
JavaScript
import test from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import { readFileSync, readdirSync, existsSync } from 'node:fs'
|
|
import ts from 'typescript'
|
|
import * as vue from 'vue'
|
|
import { compileScript, compileTemplate, parse } from '@vue/compiler-sfc'
|
|
|
|
const source = (path) => readFileSync(path, 'utf8')
|
|
function evaluate(text, imports = {}, globals = {}) {
|
|
const compiled = ts.transpileModule(text, {
|
|
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }
|
|
}).outputText
|
|
const module = { exports: {} }
|
|
new Function('require', 'module', 'exports', ...Object.keys(globals), compiled)(
|
|
(name) => {
|
|
assert.ok(imports[name], `Unexpected dependency: ${name}`)
|
|
return imports[name]
|
|
},
|
|
module,
|
|
module.exports,
|
|
...Object.values(globals)
|
|
)
|
|
return module.exports
|
|
}
|
|
const gateway = evaluate(source('src/utils/business/gateway.ts'), {
|
|
'./format': evaluate(source('src/utils/business/format.ts'))
|
|
})
|
|
const helpers = evaluate(source('src/utils/business/pollingScheduler.ts'), { './gateway': gateway })
|
|
const constants = evaluate(source('src/config/constants/pollingScheduler.ts'))
|
|
const configHelpers = evaluate(source('src/utils/business/pollingConfig.ts'), {
|
|
'./gateway': gateway,
|
|
'@/config/constants/pollingConfig': evaluate(source('src/config/constants/pollingConfig.ts'))
|
|
})
|
|
const ok = (data) => ({ code: 0, data })
|
|
const flush = async () => {
|
|
await new Promise((done) => setImmediate(done))
|
|
await vue.nextTick()
|
|
}
|
|
const batchPath = 'src/components/business/polling/PollingBatchDialog.vue'
|
|
|
|
test('gateway metrics flatten into row arrays without old raw-data attribute overriding the table', () => {
|
|
const path = 'src/components/business/gateway/GatewayOperationTable.vue'
|
|
const { descriptor } = parse(source(path))
|
|
const definition = evaluate(compileScript(descriptor, { id: 'metric-table' }).content, {
|
|
vue,
|
|
'@/components/business/polling/SchedulerTable.vue': { default: {} },
|
|
'@/utils/business/gateway': gateway
|
|
}).default
|
|
const props = {
|
|
values: [
|
|
{
|
|
carrier_id: 8,
|
|
carrier_name: 'carrier',
|
|
operations: [
|
|
{ operation: 'stop', operation_name: 'stop', success_rate: 0.8, p95_duration_ms: -1 }
|
|
]
|
|
}
|
|
]
|
|
}
|
|
const state = definition.setup(props, { expose() {}, emit() {} })
|
|
assert.equal(state.rows.value.length, 1)
|
|
assert.equal(state.format(state.rows.value[0], 'success_rate'), '80.00%')
|
|
assert.equal(state.format(state.rows.value[0], 'p95_duration_ms'), '超过最大分桶')
|
|
assert.equal(definition.inheritAttrs, false)
|
|
assert.doesNotMatch(
|
|
source('src/views/polling-management/gateway-stats/index.vue'),
|
|
/<GatewayOperationTable[^>]*:data=/s
|
|
)
|
|
})
|
|
|
|
test('every one of the 46 documented added HTTP operations has a service method', async () => {
|
|
const calls = []
|
|
class BaseService {}
|
|
for (const [method, verb] of [
|
|
['get', 'GET'],
|
|
['post', 'POST'],
|
|
['put', 'PUT'],
|
|
['delete', 'DELETE']
|
|
])
|
|
BaseService[method] = (url) => {
|
|
calls.push(`${verb} ${url}`)
|
|
return ok({})
|
|
}
|
|
for (const [file, name] of [
|
|
['gateway', 'GatewayService'],
|
|
['pollingConfig', 'PollingConfigService'],
|
|
['pollingScheduler', 'PollingSchedulerService']
|
|
]) {
|
|
const service = evaluate(source(`src/api/modules/${file}.ts`), {
|
|
'../BaseService': { BaseService }
|
|
})[name]
|
|
for (const key of Object.getOwnPropertyNames(service))
|
|
if (typeof service[key] === 'function' && !['length', 'name', 'prototype'].includes(key))
|
|
await service[key](1, {}, '')
|
|
}
|
|
const normalize = (value) =>
|
|
value
|
|
.replace('/api/admin', '')
|
|
.replace(/\{[^}]+\}/g, '{}')
|
|
.replace(/\/1(?=\/|$)/g, '/{}')
|
|
// 原始后端文档在本仓库被忽略,固定路径快照保证全新检出也可运行;本地有原文时额外校对。
|
|
const snapshot = JSON.parse(source('scripts/tests/fixtures/pollingSchedulerEndpoints.json'))
|
|
const document = 'docs/2026-10-26-轮询按运营商账户限流-前端接口变更说明.md'
|
|
if (existsSync(document))
|
|
assert.deepEqual(
|
|
[...source(document).matchAll(/^#### `(GET|POST|PUT|DELETE) ([^`]+)`/gm)].map(
|
|
(match) => `${match[1]} ${match[2]}`
|
|
),
|
|
snapshot
|
|
)
|
|
const expected = snapshot.map(normalize)
|
|
assert.equal(expected.length, 46)
|
|
const actual = new Set(calls.map(normalize))
|
|
for (const endpoint of expected) assert.ok(actual.has(endpoint), endpoint)
|
|
})
|
|
|
|
test('scheduler request rejects stale responses after reload, permission change, scope switch and unmount', async () => {
|
|
const user = vue.reactive({ info: { id: 1, user_type: 2 }, isLogin: true }),
|
|
permission = vue.ref(true),
|
|
stops = [],
|
|
deactivations = [],
|
|
activations = []
|
|
const runtime = {
|
|
...vue,
|
|
onBeforeUnmount: (fn) => stops.push(fn),
|
|
onDeactivated: (fn) => deactivations.push(fn),
|
|
onActivated: (fn) => activations.push(fn)
|
|
}
|
|
const { useSchedulerRequest } = evaluate(source('src/composables/useSchedulerRequest.ts'), {
|
|
vue: runtime,
|
|
'@/store/modules/user': { useUserStore: () => user },
|
|
'@/utils/business/gateway': gateway,
|
|
'@/utils/business/pollingScheduler': helpers
|
|
})
|
|
const pending = []
|
|
const scope = vue.effectScope()
|
|
const state = scope.run(() =>
|
|
useSchedulerRequest(
|
|
() => new Promise((resolve) => pending.push(resolve)),
|
|
() => permission.value
|
|
)
|
|
)
|
|
const a = state.load(),
|
|
b = state.load()
|
|
pending[1]('new')
|
|
await b
|
|
pending[0]('old')
|
|
await a
|
|
assert.equal(state.data.value, 'new')
|
|
const c = state.load()
|
|
user.info.id = 2
|
|
pending[2]('old account')
|
|
await c
|
|
assert.equal(state.data.value, null)
|
|
permission.value = false
|
|
await state.load()
|
|
assert.equal(pending.length, 3)
|
|
permission.value = true
|
|
const d = state.load()
|
|
stops.forEach((fn) => fn())
|
|
pending[3]('unmounted')
|
|
await d
|
|
assert.equal(state.data.value, null)
|
|
scope.stop()
|
|
})
|
|
function mount(
|
|
path,
|
|
{
|
|
userType = 2,
|
|
permissions = ['test'],
|
|
props = {},
|
|
services = {},
|
|
storage = {},
|
|
confirm = async () => 'confirm'
|
|
} = {}
|
|
) {
|
|
const user = vue.reactive({ info: { id: 1, user_type: userType }, isLogin: true })
|
|
const events = [],
|
|
calls = [],
|
|
beforeUnmount = [],
|
|
deactivated = []
|
|
const api = new Proxy(services, {
|
|
get(target, key) {
|
|
return async (...args) => {
|
|
calls.push({ name: key, args })
|
|
return key in target
|
|
? target[key](...args)
|
|
: ok({ enable: ['Enable impact from server'], disable: ['Disable impact from server'] })
|
|
}
|
|
}
|
|
})
|
|
const runtime = {
|
|
...vue,
|
|
onMounted: () => {},
|
|
onBeforeUnmount: (fn) => beforeUnmount.push(fn),
|
|
onDeactivated: (fn) => deactivated.push(fn)
|
|
}
|
|
const { descriptor } = parse(source(path))
|
|
const script = compileScript(descriptor, { id: 'scheduler-test' }).content
|
|
const imports = {
|
|
vue: runtime,
|
|
'element-plus': { ElMessageBox: { confirm } },
|
|
'@/api/modules/pollingScheduler': { PollingSchedulerService: api },
|
|
'@/api/modules/storage': { StorageService: storage },
|
|
'@/store/modules/user': { useUserStore: () => user },
|
|
'@/composables/useAuth': {
|
|
useAuth: () => ({ hasAuth: (permission) => permissions.includes(permission) })
|
|
},
|
|
'@/utils/business/gateway': gateway,
|
|
'@/utils/business/pollingScheduler': helpers,
|
|
'@/config/constants/pollingScheduler': constants,
|
|
'@vueuse/core': { useWindowSize: () => ({ width: vue.ref(1200) }) }
|
|
}
|
|
const state = evaluate(script, imports, runtime).default.setup(props, {
|
|
expose() {},
|
|
emit: (...args) => events.push(args)
|
|
})
|
|
return {
|
|
state,
|
|
calls,
|
|
user,
|
|
events,
|
|
dispose: () => beforeUnmount.forEach((fn) => fn()),
|
|
deactivate: () => deactivated.forEach((fn) => fn())
|
|
}
|
|
}
|
|
|
|
test('22 scheduler methods preserve exact HTTP paths, bodies, null and false', async () => {
|
|
const calls = []
|
|
class BaseService {}
|
|
for (const method of ['get', 'put', 'post', 'delete'])
|
|
BaseService[method] = (url, data) => {
|
|
calls.push({ method, url, data })
|
|
return ok(data)
|
|
}
|
|
const { PollingSchedulerService: service } = evaluate(
|
|
source('src/api/modules/pollingScheduler.ts'),
|
|
{ '../BaseService': { BaseService } }
|
|
)
|
|
const toggle = { asset_ids: [1, 2], enable_polling: false, reason: 'test' },
|
|
input = { inflight_limit: null },
|
|
query = { page: 1, page_size: 20, status: 1 }
|
|
const specs = [
|
|
['getCarrierParams', [], 'get', '/polling-carrier-params'],
|
|
['getCarrierParam', [8], 'get', '/polling-carrier-params/8'],
|
|
['saveCarrierParam', [8, input], 'put', '/polling-carrier-params/8', input],
|
|
['resetCarrierParam', [8], 'delete', '/polling-carrier-params/8'],
|
|
['getScenes', [], 'get', '/polling-check-scenes'],
|
|
['saveScene', ['scene/a', input], 'put', '/polling-check-scenes/scene%2Fa', input],
|
|
['getAlerts', [query], 'get', '/polling-failure-alerts', query],
|
|
['getAlertSummary', [], 'get', '/polling-failure-alerts/summary'],
|
|
['getAlert', [1], 'get', '/polling-failure-alerts/1'],
|
|
[
|
|
'handleAlert',
|
|
[1, 'note'],
|
|
'put',
|
|
'/polling-failure-alerts/1/status',
|
|
{ status: 4, remark: 'note' }
|
|
],
|
|
[
|
|
'closeAlertPolling',
|
|
[[1, 2], 'note'],
|
|
'post',
|
|
'/polling-failure-alerts/close-polling',
|
|
{ alert_ids: [1, 2], reason: 'note' }
|
|
],
|
|
['getQueues', [], 'get', '/polling-stats/carrier-queues'],
|
|
['getCardState', [42], 'get', '/iot-cards/42/polling-state'],
|
|
['getImpact', [], 'get', '/polling-status/impact'],
|
|
['toggleCards', [toggle], 'post', '/iot-cards/batch-update-polling-status', toggle],
|
|
['toggleDevices', [toggle], 'post', '/devices/batch-update-polling-status', toggle],
|
|
['getPauses', [query], 'get', '/card-polling-pauses', query],
|
|
['getImports', [query], 'get', '/polling-status-imports', query],
|
|
['getImport', [3], 'get', '/polling-status-imports/3'],
|
|
['createImport', [toggle], 'post', '/polling-status-imports', toggle],
|
|
['dryRun', [query], 'get', '/stop-resume-rule/dry-run', query],
|
|
[
|
|
'exportDryRun',
|
|
[{ carrier_id: 8, format: 'csv' }],
|
|
'post',
|
|
'/stop-resume-rule/dry-run/export',
|
|
{ carrier_id: 8, format: 'csv' }
|
|
]
|
|
]
|
|
for (const [name, args, method, path, data] of specs) {
|
|
await service[name](...args)
|
|
assert.deepEqual(calls.at(-1), { method, url: `/api/admin${path}`, data })
|
|
}
|
|
assert.equal(specs.length, 22)
|
|
})
|
|
|
|
test('role boundaries and unique 1..500 ids are enforced', () => {
|
|
for (const type of [1, 2, 3, 4, 0, undefined]) {
|
|
assert.equal(helpers.isPollingPlatform(type), [1, 2].includes(type))
|
|
assert.equal(helpers.canPollingBatch(type), [1, 2, 3].includes(type))
|
|
}
|
|
for (const ids of [[], [1, 1], [0], [-1], [1.5], Array.from({ length: 501 }, (_, i) => i + 1)])
|
|
assert.throws(() => helpers.validatePollingIds(ids))
|
|
assert.doesNotThrow(() =>
|
|
helpers.validatePollingIds(Array.from({ length: 500 }, (_, i) => i + 1))
|
|
)
|
|
assert.throws(() => helpers.pollingReason(' '))
|
|
assert.equal(helpers.pollingReason(' reason '), 'reason')
|
|
})
|
|
test('special alert view only sends documented query filters; zero status is preserved', () => {
|
|
const query = {
|
|
page: 1,
|
|
page_size: 20,
|
|
carrier_id: 8,
|
|
status: 0,
|
|
view: 'stop_refused',
|
|
check_type: 'stop_resume',
|
|
error_category: 'terminal',
|
|
alert_level: 'card',
|
|
iccid: 'x'
|
|
}
|
|
assert.deepEqual(helpers.failureQuery(query), {
|
|
page: 1,
|
|
page_size: 20,
|
|
carrier_id: 8,
|
|
status: 0,
|
|
iccid: 'x',
|
|
view: 'stop_refused'
|
|
})
|
|
assert.equal(helpers.failureQuery({ ...query, view: undefined }).check_type, 'stop_resume')
|
|
assert.deepEqual(
|
|
helpers.schedulerTime(['2026-10-26T00:00:00+08:00', '2026-10-27T00:00:00+08:00']),
|
|
{ start_time: '2026-10-25T16:00:00Z', end_time: '2026-10-26T16:00:00Z' }
|
|
)
|
|
assert.throws(() => helpers.schedulerTime(['bad', 'date']))
|
|
})
|
|
test('parameter, capacity, nullable cooldown and strictly increasing delays validate', () => {
|
|
assert.doesNotThrow(() => helpers.validateCarrierParams({ inflight_limit: null }, 0))
|
|
assert.throws(() => helpers.validateCarrierParams({ inflight_limit: 11 }, 10))
|
|
assert.throws(() => helpers.validateCarrierParams({ priority_max_ratio: 0 }, 10))
|
|
for (const delays of [[30, 30], [60, 30], [0]])
|
|
assert.throws(() =>
|
|
helpers.validateScene({ check_types: ['carddata'], confirm_delays: delays, cooldown: null })
|
|
)
|
|
assert.doesNotThrow(() =>
|
|
helpers.validateScene({ check_types: ['realname'], confirm_delays: [30, 60], cooldown: 0 })
|
|
)
|
|
assert.throws(() =>
|
|
helpers.validateScene({ check_types: ['package'], confirm_delays: [], cooldown: null })
|
|
)
|
|
assert.throws(() => helpers.validateDryAssumptions([1, 2], [2]))
|
|
assert.doesNotThrow(() => helpers.validateDryAssumptions([1], [2]))
|
|
})
|
|
test('CSV header, quoted newlines, escaped quotes, empty lines, 50000 limit and string ids', () => {
|
|
const id = '89860426012345678901'
|
|
assert.equal(
|
|
helpers.validatePollingCsv(
|
|
'file.CSV',
|
|
`\uFEFFiccid,note\r\n${id},"line\nline"\r\n\r\n${id},"a""b"`
|
|
),
|
|
2
|
|
)
|
|
assert.equal(id, '89860426012345678901')
|
|
for (const [name, text] of [
|
|
['f.txt', 'h\nx'],
|
|
['f.csv', 'header'],
|
|
['f.csv', 'h\n"x'],
|
|
['f.csv', `h\n${'x\n'.repeat(50001)}`]
|
|
])
|
|
assert.throws(() => helpers.validatePollingCsv(name, text))
|
|
assert.equal(helpers.validatePollingCsv('f.csv', `h\n${'x\n'.repeat(50000)}`), 50000)
|
|
})
|
|
test('config save persists new fields, removes protection, uses clear_fields without losing zero', () => {
|
|
const draft = {
|
|
...configHelpers.newPollingDraft(),
|
|
config_name: 'x',
|
|
asset_status: 2,
|
|
package_state: 'active',
|
|
carddata_check_interval: 0,
|
|
protect_check_interval: 30
|
|
}
|
|
const body = configHelpers.pollingSaveBody(draft, true)
|
|
assert.equal(body.asset_status, 2)
|
|
assert.equal(body.package_state, 'active')
|
|
assert.equal(body.carddata_check_interval, 0)
|
|
assert.ok(body.clear_fields.includes('realname_check_interval'))
|
|
assert.equal('realname_check_interval' in body, false)
|
|
assert.equal('protect_check_interval' in body, false)
|
|
assert.equal('id' in body, false)
|
|
assert.equal('disabled' in body, false)
|
|
assert.deepEqual(
|
|
configHelpers.pollingDraftBody([{ id: 3, disabled: true, priority: -1, config_name: '' }]),
|
|
[{ id: 3, disabled: true }]
|
|
)
|
|
assert.throws(() =>
|
|
gateway.validateGatewayRule({
|
|
category: 'business',
|
|
auto_disable_polling: true,
|
|
keyword: 'a',
|
|
match_type: 'contains'
|
|
})
|
|
)
|
|
})
|
|
|
|
for (const [target, type, allowed] of [
|
|
['iot_card', 3, true],
|
|
['device', 4, false],
|
|
['alert', 3, false],
|
|
['alert', 2, true]
|
|
])
|
|
test(`batch ${target} type${type} validates identity and shows partial results`, async () => {
|
|
const result = {
|
|
success_count: 1,
|
|
skip_count: 1,
|
|
fail_count: 2,
|
|
impact: ['impact'],
|
|
items: [1, 2, 3, 4].map((status) => ({
|
|
asset_id: status,
|
|
identifier: String(status),
|
|
status,
|
|
status_name: String(status),
|
|
reason: 'reason'
|
|
}))
|
|
}
|
|
const h = mount(batchPath, {
|
|
userType: type,
|
|
props: { target, ids: [1, 2, 3, 4], permission: 'test' },
|
|
services: {
|
|
toggleCards: () => ok(result),
|
|
toggleDevices: () => ok(result),
|
|
closeAlertPolling: () => ok(result)
|
|
}
|
|
})
|
|
await h.state.loadImpact()
|
|
h.state.reason.value = ' reason '
|
|
await h.state.submit()
|
|
assert.equal(
|
|
h.calls.some((call) =>
|
|
['toggleCards', 'toggleDevices', 'closeAlertPolling'].includes(call.name)
|
|
),
|
|
allowed
|
|
)
|
|
if (allowed) {
|
|
assert.deepEqual(h.state.result.value, result)
|
|
assert.equal(h.state.resultRows.value.length, 4)
|
|
await h.state.submit()
|
|
assert.equal(h.calls.length, 2)
|
|
}
|
|
h.dispose()
|
|
})
|
|
test('batch missing impact, cancel, duplicate ids, unmount, and changed scope never write', async () => {
|
|
for (const variant of ['impact', 'cancel', 'duplicate', 'unmount', 'scope']) {
|
|
const props = {
|
|
target: 'iot_card',
|
|
ids: variant === 'duplicate' ? [1, 1] : [1],
|
|
permission: 'test'
|
|
}
|
|
let h
|
|
h = mount(batchPath, {
|
|
props,
|
|
services:
|
|
variant === 'impact'
|
|
? {
|
|
getImpact: () => {
|
|
throw Error('offline')
|
|
}
|
|
}
|
|
: {},
|
|
confirm: async () => {
|
|
if (variant === 'cancel') throw 'cancel'
|
|
if (variant === 'unmount') h.dispose()
|
|
if (variant === 'scope') h.user.info.id = 2
|
|
}
|
|
})
|
|
await h.state.loadImpact()
|
|
h.state.reason.value = 'reason'
|
|
await h.state.submit()
|
|
assert.equal(
|
|
h.calls.some((call) => call.name === 'toggleCards'),
|
|
false,
|
|
variant
|
|
)
|
|
h.dispose()
|
|
}
|
|
})
|
|
test('import validates purpose/prefix and never creates after upload failure', async () => {
|
|
for (const fail of [false, true]) {
|
|
const uploads = []
|
|
const h = mount('src/components/business/polling/PollingImportDialog.vue', {
|
|
permissions: [constants.SCHEDULER_PERMISSIONS.imports.create],
|
|
storage: {
|
|
getUploadUrl: async (data) => {
|
|
uploads.push(data)
|
|
return ok({ upload_url: 'local-upload', file_key: 'polling-status-imports/test.csv' })
|
|
},
|
|
uploadFile: async () => {
|
|
if (fail) throw Error('upload failed')
|
|
}
|
|
},
|
|
services: { createImport: () => ok({ id: 3 }) }
|
|
})
|
|
h.state.file.value = { name: 'file.csv', text: async () => 'iccid\n89860426012345678901' }
|
|
h.state.reason.value = 'reason'
|
|
await h.state.submit()
|
|
assert.equal(uploads[0].purpose, 'polling_status_import')
|
|
assert.equal(
|
|
h.calls.some((call) => call.name === 'createImport'),
|
|
!fail
|
|
)
|
|
h.dispose()
|
|
}
|
|
})
|
|
test('all new Vue templates compile, including multi-statement event handlers', () => {
|
|
const dirs = [
|
|
'src/components/business/polling',
|
|
'src/views/polling-management/carrier-params',
|
|
'src/views/polling-management/check-scenes',
|
|
'src/views/polling-management/carrier-queues',
|
|
'src/views/polling-management/failure-alerts',
|
|
'src/views/polling-management/polling-pauses',
|
|
'src/views/polling-management/polling-status-imports',
|
|
'src/views/polling-management/stop-resume-dry-run'
|
|
]
|
|
for (const dir of dirs)
|
|
for (const file of readdirSync(dir).filter((file) => file.endsWith('.vue'))) {
|
|
const path = `${dir}/${file}`,
|
|
{ descriptor, errors } = parse(source(path))
|
|
assert.deepEqual(errors, [], path)
|
|
const result = compileTemplate({
|
|
source: descriptor.template.content,
|
|
filename: path,
|
|
id: path,
|
|
compilerOptions: { expressionPlugins: ['typescript'] }
|
|
})
|
|
assert.deepEqual(result.errors, [], path)
|
|
}
|
|
})
|
|
test('protection is absent from admin forms and polling pause/alert/dry-run export scenes are registered', () => {
|
|
for (const path of [
|
|
'src/views/polling-management/config/index.vue',
|
|
'src/views/polling-management/config/components/PollingDraftFields.vue',
|
|
'src/views/asset-management/asset-information/components/BasicInfoCard.vue'
|
|
])
|
|
assert.equal(/protect_check_interval|device_protect_status/.test(source(path)), false, path)
|
|
for (const scene of ['polling_pause', 'polling_stop_refused_alert', 'stop_resume_rule_dry_run'])
|
|
assert.ok(source('src/config/constants/exportTask.ts').includes(`scene: '${scene}'`))
|
|
for (const kind of [
|
|
'carrier-params',
|
|
'check-scenes',
|
|
'failure-alerts',
|
|
'carrier-queues',
|
|
'stop-resume-dry-run',
|
|
'polling-pauses',
|
|
'polling-status-imports'
|
|
])
|
|
assert.ok(source('src/router/routes/asyncRoutes.ts').includes(`path: '${kind}'`))
|
|
assert.match(
|
|
source('src/views/polling-management/stop-resume-dry-run/index.vue'),
|
|
/assume_control_off/
|
|
)
|
|
})
|