import test from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import ts from 'typescript' import * as vue from 'vue' import { compileScript, parse } from '@vue/compiler-sfc' function evaluate(source, imports) { const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText const module = { exports: {} } new Function('require', 'module', 'exports', compiled)( (name) => { assert.ok(imports[name], `Unexpected runtime dependency: ${name}`) return imports[name] }, module, module.exports ) return module.exports } const basePath = 'src/views/asset-management/asset-information' const { descriptor } = parse(readFileSync(`${basePath}/components/BasicInfoCard.vue`, 'utf8')) const componentSource = compileScript(descriptor, { id: 'binding-card-test', inlineTemplate: true }).content const formatters = evaluate(readFileSync(`${basePath}/composables/useAssetFormatters.ts`, 'utf8'), { '@/config/constants/carrierTypes': { getCarrierTypeLabel: (code) => ({ CTCC: '中国电信', CMCC: '中国移动' })[code] || code } }) function createHostNode(tag, text = '') { return { tag, text, props: {}, children: [], parentNode: null, removeChild(child) { const index = this.children.indexOf(child) assert.notEqual(index, -1) this.children.splice(index, 1) child.parentNode = null } } } function mountBasicInfo(t, userType, permissions = []) { const user = vue.reactive({ info: { id: userType, user_type: userType }, permissions }) const hasAuth = (permission) => Number(user.info.user_type) === 1 || user.permissions.includes(permission) user.hasPermission = hasAuth user.hasButton = hasAuth const elements = Object.fromEntries( [ 'ElCard', 'ElDescriptions', 'ElDescriptionsItem', 'ElTag', 'ElTooltip', 'ElSwitch', 'ElDivider', 'ElButton', 'ElAlert', 'ElEmpty' ].map((name) => [ name, vue.defineComponent({ name, setup: (_, { slots, attrs }) => () => vue.h(name, attrs, [slots.header?.(), slots.default?.()]) }) ]) ) const component = evaluate(componentSource, { vue, '@vueuse/core': { useWindowSize: () => ({ width: vue.ref(1200) }) }, 'element-plus': { ...elements, ElMessageBox: {}, ElMessage: {} }, '@element-plus/icons-vue': { CopyDocument: vue.defineComponent(() => () => vue.h('copy-icon')) }, '../composables/useAssetFormatters': formatters, '@/composables/useAuth': { useAuth: () => ({ hasAuth }) }, '@/components/business/package-silence/SilenceRecordsDialog.vue': { default: vue.defineComponent(() => () => null) }, '@/store/modules/user': { useUserStore: () => user }, '@/config/constants': { JULY_PERMISSIONS: { speedTier: { view: 'speed:view', update: 'speed:update' } } }, '@/utils/business/format': { formatDateTime: (value) => String(value || '') }, '@/utils/business/packageSilence': evaluate( readFileSync('src/utils/business/packageSilence.ts', 'utf8') ), '@/utils/business/expiryEstimate': { getExpiryEstimateText: () => '-', getExpiryEstimateTooltip: () => '', getExpiryEstimateClass: () => '' }, '@/config/constants/audit': { AUDIT_PERMISSIONS: {} }, '@/config/constants/trafficRecords': { TRAFFIC_RECORD_PERMISSIONS: { assetCollections: { card: 'iot_info:traffic_collections', device: 'device_info:traffic_collections' } } }, '@/utils/business/trafficRecords': { isEnterpriseAccount: (type) => type === 4 }, '@/utils/business/auditNavigation': { resolveAuditResourceTarget: () => null }, '@/components/business/audit/investigationController': { openAuditInvestigation: () => {} } }).default const renderer = vue.createRenderer({ createElement: (tag) => createHostNode(tag), createText: (text) => createHostNode('#text', text), createComment: (text) => createHostNode('#comment', text), setText: (node, text) => { node.text = text }, setElementText: (node, text) => { node.text = text node.children = [] }, parentNode: (node) => node.parentNode, nextSibling: (node) => { const siblings = node.parentNode?.children || [] return siblings[siblings.indexOf(node) + 1] || null }, patchProp: (node, key, _, value) => { node.props[key] = value }, insert: (node, parent, anchor) => { node.parentNode?.removeChild(node) const index = anchor ? parent.children.indexOf(anchor) : parent.children.length assert.notEqual(index, -1, 'Vue tried to insert beside a node removed outside its renderer') parent.children.splice(index, 0, node) node.parentNode = parent }, remove: (node) => node.parentNode?.removeChild(node) }) const asset = vue.reactive({ asset_type: 'device', asset_id: 101, identifier: 'DEVICE-A', cards: [ { iccid: 'CARD-1', msisdn: '13800000001', carrier_name: '平台运营商账户', carrier_type: 'CTCC', slot_position: 1, is_current: true, network_status: 1 }, { iccid: 'CARD-2', msisdn: '13800000002', carrier_name: '第二运营商账户', carrier_type: 'CMCC', slot_position: 2, is_current: false, network_status: 0 } ] }) const root = createHostNode('root') const app = renderer.createApp({ render: () => vue.h(component, { cardInfo: asset, deviceRealtime: null, pollingEnabled: false }) }) const directives = evaluate(readFileSync('src/directives/permission.ts', 'utf8'), { '@/store/modules/user': { useUserStore: () => user } }) directives.setupPermissionDirective(app) app.directive('loading', {}) app.component( 'el-icon', vue.defineComponent({ setup: (_, { slots }) => () => vue.h('icon', slots.default?.()) }) ) app.mount(root) t.after(() => app.unmount()) return { root, asset, user } } const textOf = (node) => node.tag === '#comment' ? '' : node.text + node.children.map(textOf).join('') test('enterprise account hides polling and realname writes while authorized start/stop remain', async (t) => { const { root, asset, user } = mountBasicInfo(t, 4) user.permissions = [ 'device_info:auto_polling', 'iot_info:auto_polling', 'iot_info:update_realname_status', 'bound_card:update_realname_status', 'iot_info:start', 'iot_info:stop' ] await vue.nextTick() assert.doesNotMatch(textOf(root), /自动轮询|更新实名状态/) assert.equal(nodesWithClass(root, 'binding-card').length, 2) asset.asset_type = 'card' asset.network_status = 0 await vue.nextTick() assert.match(textOf(root), /启用此卡/) assert.doesNotMatch(textOf(root), /自动轮询|更新实名状态/) asset.network_status = 1 await vue.nextTick() assert.match(textOf(root), /停用此卡/) }) const nodesWithClass = (node, className) => [ ...(String(node.props.class || '') .split(' ') .includes(className) ? [node] : []), ...node.children.flatMap((child) => nodesWithClass(child, className)) ] test('all account types render both binding cards; only the established carrier label rule differs', async (t) => { for (const userType of [1, 2, 3, 4]) { const { root } = mountBasicInfo(t, userType) await vue.nextTick() const cards = nodesWithClass(root, 'binding-card') assert.equal(cards.length, 2) assert.match(textOf(cards[0]), /CARD-1/) assert.match(textOf(cards[1]), /CARD-2/) assert.match(textOf(cards[0]), /13800000001/) assert.match(textOf(cards[0]), userType < 3 ? /平台运营商账户/ : /中国电信/) } }) test('permission changes and current-card/status updates preserve card data without external DOM removal', async (t) => { const { root, asset, user } = mountBasicInfo(t, 3, ['iot_info:stop']) await vue.nextTick() assert.match(textOf(nodesWithClass(root, 'binding-card-actions')[0]), /停用此卡/) user.permissions = ['iot_info:start', 'bound_card:update_realname_status'] asset.cards[0].network_status = 0 asset.cards[0].is_current = false asset.cards[1].is_current = true await vue.nextTick() const cards = nodesWithClass(root, 'binding-card') assert.equal(cards.length, 2) assert.match(textOf(cards[0]), /CARD-2/) assert.match(textOf(cards[1]), /CARD-1/) assert.match(textOf(nodesWithClass(cards[1], 'binding-card-actions')[0]), /启用此卡/) assert.match(textOf(nodesWithClass(cards[1], 'binding-card-actions')[0]), /更新实名状态/) assert.doesNotMatch(textOf(nodesWithClass(cards[1], 'binding-card-actions')[0]), /停用此卡/) user.permissions = [] await vue.nextTick() assert.equal(nodesWithClass(root, 'binding-card').length, 2) for (const actions of nodesWithClass(root, 'binding-card-actions')) assert.equal(textOf(actions).trim(), '') }) test('missing network status renders unknown and exposes neither start nor stop', async (t) => { const { root, asset } = mountBasicInfo(t, 3, ['iot_info:start', 'iot_info:stop']) delete asset.cards[0].network_status await vue.nextTick() const card = nodesWithClass(root, 'binding-card')[0] assert.match(textOf(card), /网络状态未知/) assert.doesNotMatch(textOf(nodesWithClass(card, 'binding-card-actions')[0]), /启用此卡|停用此卡/) }) test('read-only accounts render realtime start/stop transitions without losing binding-card nodes', async (t) => { for (const userType of [2, 3, 4]) { const { root, asset } = mountBasicInfo(t, userType) asset.cards[0].network_status = 0 await vue.nextTick() assert.equal(nodesWithClass(root, 'binding-card').length, 2) assert.match(textOf(nodesWithClass(root, 'binding-card')[0]), /网络状态停机/) asset.cards[0].network_status = 1 await vue.nextTick() assert.match(textOf(nodesWithClass(root, 'binding-card')[0]), /网络状态正常/) for (const actions of nodesWithClass(root, 'binding-card-actions')) { assert.equal(textOf(actions).trim(), '') } } }) test('current-first sorting does not mutate source cards; zero, one, and multiple bindings render stably', async (t) => { const { root, asset } = mountBasicInfo(t, 2) const originalOrder = asset.cards.map((card) => card.iccid) asset.cards[0].is_current = false asset.cards[1].is_current = true await vue.nextTick() assert.deepEqual( asset.cards.map((card) => card.iccid), originalOrder ) assert.match(textOf(nodesWithClass(root, 'binding-card')[0]), /CARD-2/) asset.cards = [asset.cards[1]] await vue.nextTick() assert.equal(nodesWithClass(root, 'binding-card').length, 1) asset.cards = [] await vue.nextTick() assert.equal(nodesWithClass(root, 'binding-card').length, 0) }) test('device/card type switches and denied actions do not leave removed DOM anchors', async (t) => { const { root, asset, user } = mountBasicInfo(t, 3) asset.asset_type = 'card' asset.iccid = 'CARD-1' asset.network_status = 1 await vue.nextTick() asset.network_status = 0 await vue.nextTick() assert.equal(textOf(nodesWithClass(root, 'card-operations')[0]).trim(), '') user.permissions = ['iot_info:start'] await vue.nextTick() assert.match(textOf(nodesWithClass(root, 'card-operations')[0]), /启用此卡/) asset.asset_type = 'device' user.permissions = ['device_info:reboot'] await vue.nextTick() assert.equal(nodesWithClass(root, 'binding-card').length, 2) assert.match(textOf(nodesWithClass(root, 'device-operations')[0]), /重启设备/) })