Files
Evergreen-Health-Mobile/scripts/test-device-pages.mjs
T
2026-10-09 16:46:35 +08:00

309 lines
23 KiB
JavaScript

// 渲染真实 Vue 页面,注入假读数;不启动 HTTP 服务、不请求后端、不操作真实家具。
import assert from 'node:assert/strict'
import { after, test } from 'node:test'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { createServer } from 'vite'
import vue from '@vitejs/plugin-vue'
import { createSSRApp, h, ref } from 'vue'
import { renderToString } from 'vue/server-renderer'
import { createRouter, createMemoryHistory, RouterView } from 'vue-router'
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const server = await createServer({ configFile: false, root, plugins: [vue()], appType: 'custom',
server: { middlewareMode: true, hmr: false }, optimizeDeps: { noDiscovery: true, include: [] } })
after(() => server.close())
const { default: Overview } = await server.ssrLoadModule('/src/views/DeviceControlView.vue')
const { default: Detail } = await server.ssrLoadModule('/src/views/DeviceDetailView.vue')
const { deviceControlKey } = await server.ssrLoadModule('/src/devices/controlContext.ts')
const { createDeviceParameterState, syncDeviceParameters } = await server.ssrLoadModule('/src/devices/parameterRegistry.ts')
const mattressParams = await server.ssrLoadModule('/src/devices/mattress/parameters.ts')
const { deviceDetailLocation } = await server.ssrLoadModule('/src/devices/deviceKinds.ts')
const { parameterIsAvailable, parameterHasReading } = await server.ssrLoadModule('/src/devices/parameterState.ts')
const { primaryDeviceEntity, reportedPower } = await server.ssrLoadModule('/src/devices/deviceSummary.ts')
const { climateCapabilities } = await server.ssrLoadModule('/src/devices/climate/capabilities.ts')
const reading = (entityId, domain, state, attributes = {}) => ({ entityId, domain, name: entityId, state, attributes, available: true })
const device = (deviceId, name, moduleKey, category, entities = [], extra = {}) => ({
deviceId, name, moduleKey, category, entities, online: true, manufacturer: '测试品牌', model: '测试型号', areaName: '睡眠空间', ...extra,
})
const devices = [
device('lamp', '演示卧室灯', 'lighting', 'Lighting', [reading('light.room', 'light', 'on', { brightness: 128 })], { primaryControlEntityId: 'light.room' }),
device('mattress', '演示智能床垫', 'mattress', 'Sleep'),
device('fragrance', '演示香氛机', 'fragrance', 'Fragrance', [reading('switch.fragrance', 'switch', 'off')], { primaryControlEntityId: 'switch.fragrance' }),
device('speaker', '演示音箱', 'audio', 'Media', [reading('media_player.room', 'media_player', 'idle', { volume_level: .7 })], { primaryControlEntityId: 'media_player.room' }),
]
const unexpectedCommand = () => { throw new Error('页面检查不允许发出家具命令') }
async function render(location = '/device-control', overrides = {}) {
const router = createRouter({ history: createMemoryHistory(), routes: [
{ path: '/device-control', component: { render: () => h(RouterView) }, children: [
{ path: '', name: 'device-overview', component: Overview },
{ path: ':deviceId', name: 'device-detail', component: Detail },
] },
] })
const context = {
router, loading: ref(false), operationBusy: ref(false), tcmResult: ref({ hasCompleted: true, constitutionName: '平和质' }),
haStatus: ref({ connected: true, message: '', devices, summary: { totalDevices: 4, onlineDevices: 4 },
environment: { temperature: 25.6, humidity: 58.8 }, controlParameters: {} }),
musicTransition: ref({ running: true, hasVolume: true, volumePercent: 46 }), lightTransition: ref(),
sceneRunning: ref(), switchingDeviceId: ref(), settingParam: ref(), actionFeedback: ref(), currentOperationText: ref(''), lastUpdatedAt: ref(''),
localParams: createDeviceParameterState(), mattressComfort: ref(), mattressActiveSide: ref('left'),
...mattressParams,
isDeviceTurnedOn: value => Boolean(reportedPower(primaryDeviceEntity(value))),
isDeviceControlAvailable: value => primaryDeviceEntity(value)?.domain === 'climate' ? climateCapabilities(value).canToggle
: Boolean(value?.online && reportedPower(primaryDeviceEntity(value)) !== null &&
['switch', 'humidifier', 'light', 'media_player'].includes(primaryDeviceEntity(value)?.domain)),
isParameterAvailable: (parameter, value) => parameterIsAvailable(context.haStatus.value?.controlParameters, parameter, value),
hasParameterReading: (parameter, value) => parameterHasReading(context.haStatus.value?.controlParameters, parameter, value),
toggleDevice: unexpectedCommand, controlSpeaker: unexpectedCommand, applyParam: unexpectedCommand,
executeScene: unexpectedCommand, loadStatus: unexpectedCommand, controlLightTransition: unexpectedCommand,
setMattressMode: unexpectedCommand, setMattressSelect: unexpectedCommand, ...overrides,
}
await router.push(location); await router.isReady()
const app = createSSRApp({ render: () => h(RouterView) })
app.use(router); app.provide(deviceControlKey, context)
return { html: await renderToString(app), router }
}
const climateAttributes = () => ({ hvac_modes: ['cool', 'heat', 'fan_only', 'off'], min_temp: 5, max_temp: 35, target_temp_step: .5,
temperature_unit: '°C',
fan_modes: ['自动', '低风', '中风', '高风'], current_temperature: 24.1, temperature: 23, fan_mode: '低风', supported_features: 393 })
const newDeviceSnapshot = item => {
const entity = item.entities.find(value => value.entityId === item.primaryControlEntityId)
const keys = item.moduleKey === 'climate' ? ['climate_mode', 'climate_temperature', 'climate_fan_mode'] : ['curtain_action', 'curtain_position']
const controlParameters = Object.fromEntries(keys.map(key => [key, { entityId: entity.entityId, state: entity.state, available: entity.available, attributes: entity.attributes }]))
return { connected: true, devices: [item], summary: { totalDevices: 1, onlineDevices: item.online ? 1 : 0 }, controlParameters }
}
const renderNewDevice = async (item, change = {}) => {
const snapshot = { ...newDeviceSnapshot(item), ...change }, localParams = createDeviceParameterState()
syncDeviceParameters(localParams, snapshot.controlParameters)
return render(deviceDetailLocation(item.deviceId), { haStatus: ref(snapshot), localParams })
}
test('climate detail exposes only real supported modes, range and fan options', async () => {
const item = device('climate-demo', '演示空调', 'climate', 'Climate', [reading('climate.room', 'climate', 'off', climateAttributes())], { primaryControlEntityId: 'climate.room' })
const { html } = await renderNewDevice(item)
for (const text of ['室温调节', '当前室温', '24.1℃', '23℃', '空调运行模式', '制冷', '制热', '送风', '空调目标温度', '每档 0.5℃', '5℃', '35℃', '低风', '高风'])
assert.ok(html.includes(text), text)
for (const text of ['摆风', '校准', '恢复出厂', '演示智能床垫']) assert.ok(!html.includes(text), text)
assert.ok(html.includes('van-slider'))
const modeButtons = html.match(/<button[^>]*aria-pressed[^>]*>[\s\S]*?<\/button>/g)
assert.equal(modeButtons.length, 8)
assert.equal(modeButtons.filter(value => value.includes('disabled')).length, 0)
})
test('climate missing target or range never renders a default temperature slider', async () => {
const attributes = climateAttributes(); delete attributes.temperature
const item = device('climate-missing', '温度缺失空调', 'climate', 'Climate', [reading('climate.room', 'climate', 'cool', attributes)], { primaryControlEntityId: 'climate.room' })
let html = (await renderNewDevice(item)).html
assert.ok(html.includes('不显示默认温度')); assert.ok(!html.includes('van-slider')); assert.ok(!html.includes('0℃'))
attributes.temperature = 23; delete attributes.min_temp
html = (await renderNewDevice(item)).html
assert.ok(!html.includes('van-slider')); assert.ok(!html.includes('23℃'))
attributes.min_temp = 5; attributes.temperature_unit = '°F'
html = (await renderNewDevice(item)).html
assert.ok(html.includes('23℉')); assert.ok(html.includes('24.1℉')); assert.ok(!html.includes('23℃'))
assert.ok(html.includes('van-slider--disabled')); assert.ok(html.includes('此版仅支持摄氏温度调节'))
delete attributes.temperature_unit
html = (await renderNewDevice(item)).html
assert.ok(!html.includes('van-slider')); assert.ok(!html.includes('23℃')); assert.ok(!html.includes('24.1℃'))
})
test('climate offline main entity does not borrow auxiliary availability or another device parameter', async () => {
const item = device('climate-offline', '主控离线空调', 'climate', 'Climate', [
{ ...reading('climate.room', 'climate', 'unavailable', climateAttributes()), available: false }, reading('sensor.alive', 'sensor', '1'),
], { primaryControlEntityId: 'climate.room' })
const { html } = await renderNewDevice(item)
assert.ok(html.includes('空调当前不可用')); assert.ok(!html.includes('24.1℃')); assert.ok(!html.includes('23℃'))
assert.ok(!html.includes('van-slider'))
const buttons = html.match(/<button[^>]*aria-pressed[^>]*>[\s\S]*?<\/button>/g)
assert.ok(buttons.every(value => value.includes('disabled')))
const owned = device('climate-owned', '未拥有参数空调', 'climate', 'Climate', [reading('climate.room', 'climate', 'off', climateAttributes())], { primaryControlEntityId: 'climate.room' })
const snapshot = newDeviceSnapshot(owned)
for (const control of Object.values(snapshot.controlParameters)) control.entityId = 'climate.foreign'
const foreignHtml = (await renderNewDevice(owned, snapshot)).html
assert.ok(!foreignHtml.includes('van-slider')); assert.ok(!foreignHtml.includes('23℃'))
assert.ok(foreignHtml.match(/<button[^>]*aria-pressed[^>]*>[\s\S]*?<\/button>/g).every(value => value.includes('disabled')))
})
test('curtain unavailable detail keeps three disabled actions without inventing a position or generic power switch', async () => {
const item = device('curtain-offline', '演示窗帘', 'curtain', 'Curtain', [
{ ...reading('cover.room', 'cover', 'unavailable', { supported_features: 15 }), available: false },
], { primaryControlEntityId: 'cover.room', online: false })
const { html } = await renderNewDevice(item)
for (const text of ['窗帘开合', '打开', '停止', '关闭', '窗帘开度', '开度暂未回读', '窗帘当前不可用']) assert.ok(html.includes(text), text)
assert.ok(!html.includes('van-slider')); assert.ok(!html.includes('0% 完全关闭'))
assert.ok(!html.includes('mini-toggle')); assert.ok(!/<span class="status-dot[^>]*>已关闭<\/span>/.test(html))
const group = html.match(/<div class="btn-group device-mode-buttons curtain-actions">[\s\S]*?<\/div>/)[0]
assert.equal((group.match(/<button/g) ?? []).length, 3)
assert.equal((group.match(/disabled/g) ?? []).length, 3)
})
test('curtain online missing position permits feature-supported actions but no fake slider; zero is a valid reading', async () => {
const item = device('curtain-ready', '可用窗帘', 'curtain', 'Curtain', [reading('cover.room', 'cover', 'open', { supported_features: 15 })], { primaryControlEntityId: 'cover.room' })
let html = (await renderNewDevice(item)).html
assert.ok(html.includes('已打开')); assert.ok(!html.includes('van-slider'))
let group = html.match(/<div class="btn-group device-mode-buttons curtain-actions">[\s\S]*?<\/div>/)[0]
assert.ok(!group.includes('disabled'))
item.entities[0].attributes.current_position = 0; item.entities[0].state = 'closed'
html = (await renderNewDevice(item)).html
assert.ok(html.includes('已关闭')); assert.ok(html.includes('0%')); assert.ok(html.includes('van-slider'))
item.entities[0].attributes.supported_features = 1
html = (await renderNewDevice(item)).html
assert.ok(!html.includes('窗帘开度')); assert.ok(!html.includes('van-slider'))
group = html.match(/<div class="btn-group device-mode-buttons curtain-actions">[\s\S]*?<\/div>/)[0]
assert.equal((group.match(/disabled/g) ?? []).length, 2)
})
test('unbound devices in new categories remain generic and cannot borrow bound controls', async () => {
for (const category of ['Climate', 'Curtain']) {
const domain = category === 'Climate' ? 'climate' : 'cover'
const item = device(`unbound-${domain}`, `未绑定${category}`, '', category, [reading(`${domain}.room`, domain, 'off', climateAttributes())], { primaryControlEntityId: `${domain}.room` })
const { html } = await renderNewDevice(item)
assert.ok(html.includes('设备能力'))
for (const text of ['空调运行模式', '空调目标温度', '窗帘开合', '窗帘开度']) assert.ok(!html.includes(text), text)
}
})
test('overview has compact tiles and room tabs without detailed sliders or mattress controls', async () => {
const { html } = await render()
assert.equal((html.match(/<article class="device-overview-tile/g) ?? []).length, 4)
for (const value of ['一键启动全屋舒睡模式', '一键关闭设备', '全屋', '睡眠空间', '音量 46%', '演示智能床垫']) assert.ok(html.includes(value), value)
for (const value of ['左侧加热档位', '按摩力度', '灯光色温调节', 'van-slider']) assert.ok(!html.includes(value), value)
// 快捷开关与进入详情按钮是同级元素,不会发生嵌套按钮或点击开关误进详情。
for (const tile of html.match(/<article class="device-overview-tile[\s\S]*?<\/article>/g) ?? []) {
const mainButton = tile.match(/<button class="device-tile-open"[\s\S]*?<\/button>/)?.[0]
assert.ok(mainButton)
assert.ok(!mainButton.includes('device-tile-power'))
}
})
const outlet = () => device('outlet', '红灯插座', 'outlet', 'Outlet', [
reading('switch.service', 'switch', 'on'), reading('switch.power', 'switch', 'off'),
{ ...reading('number.protection', 'number', '8472528'), name: '充电保护、过载保护' },
{ ...reading('sensor.energy', 'sensor', '0', { device_class: 'energy' }), unit: 'kWh' },
], { primaryControlEntityId: 'switch.power' })
const outletSnapshot = item => ({ connected: true, devices: [item],
summary: { totalDevices: 1, onlineDevices: item.online ? 1 : 0 }, controlParameters: {} })
test('outlet detail renders its own on/off control and real energy without service or packed engineering controls', async () => {
const item = outlet(), snapshot = outletSnapshot(item)
let html = (await render('/device-control/outlet', { haStatus: ref(snapshot) })).html
for (const text of ['智能插座', '插座可用', '开启电源', '插座电源状态', '已断电', '累计用电', '0 kWh', '暂不参与一键舒睡']) assert.ok(html.includes(text), text)
for (const text of ['8472528', '充电保护、过载保护', 'switch.service', '查看全部能力', '设备能力']) assert.ok(!html.includes(text), text)
assert.ok(!html.match(/<button[^>]*disabled[^>]*>[\s\S]*?开启电源/))
item.entities[1].state = 'on'
html = (await render('/device-control/outlet', { haStatus: ref(snapshot) })).html
assert.ok(html.includes('关闭电源')); assert.ok(html.includes('已通电'))
})
test('outlet detail does not report power off when main state is missing despite online service switch', async () => {
const item = outlet(); item.entities[1].state = 'unknown'
const { html } = await render('/device-control/outlet', { haStatus: ref(outletSnapshot(item)) })
assert.ok(html.includes('插座主开关当前不可用')); assert.ok(html.includes('暂不可用'))
assert.ok(html.includes('<strong class="p-val">--</strong>')); assert.ok(!html.includes('已断电'))
const card = html.match(/<section class="device-card"[\s\S]*?<\/section>/)[0]
assert.ok(card.match(/<button[^>]*disabled/)); assert.ok(!card.includes('开启电源'))
})
test('outlet overview has a dedicated compact tile and trusted quick power switch', async () => {
const { html } = await render('/device-control', { haStatus: ref(outletSnapshot(outlet())) })
for (const text of ['智能插座', '红灯插座', '已断电', '累计用电 0kWh', 'device-tile-power']) assert.ok(html.includes(text), text)
for (const text of ['8472528', '充电保护、过载保护', '插座电源状态']) assert.ok(!html.includes(text), text)
})
test('mattress detail retains full controls and never renders other devices or scene-wide actions', async () => {
const { html } = await render('/device-control/mattress')
assert.equal((html.match(/<section class="device-card"/g) ?? []).length, 1)
for (const value of ['演示智能床垫', '全部设备', '左侧加热档位', '右侧加热档位', '左侧承托模式', '右侧承托模式', '左肩软硬度', '右腿软硬度', '左侧按摩部位', '右侧按摩部位', '按摩力度']) assert.ok(html.includes(value), value)
for (const value of ['演示卧室灯', '演示香氛机', '一键启动全屋舒睡模式', 'device-overview-tile']) assert.ok(!html.includes(value), value)
})
test('fragrance detail retains separate nightlight and unavailable quick-release feedback', async () => {
const { html } = await render('/device-control/fragrance')
for (const value of ['单次出香时长', '自动出香间隔', '感应夜灯', '已关闭', '暂不可用']) assert.ok(html.includes(value), value)
assert.ok(!html.includes('演示智能床垫'))
})
test('each registered device detail renders its own parameters without requesting the network', async () => {
const cases = [
[devices[0], ['灯光亮度', '灯光色温调节']],
[devices[3], ['音箱音量', '音箱睡眠模式']],
[device('dehumidifier', '演示除湿机', 'dehumidifier', 'EnvironmentControl', [reading('humidifier.room', 'humidifier', 'off')],
{ primaryControlEntityId: 'humidifier.room' }), ['恒湿目标湿度', '除湿运行模式', '除湿机风速档位', 'UV 深度紫外消杀']],
[device('environment', '演示环境检测仪', 'environment', 'EnvironmentSensor'), ['温度', '湿度', 'PM2.5', 'CO2 浓度', '甲醛 HCHO', 'TVOC']],
[device('scale', '演示体脂秤', '', 'BodyComposition'), ['健康身体成分', '健康档案']],
[device('generic', '未接入模块的灯', '', 'Lighting', [reading('sensor.example', 'sensor', '12')]), ['设备能力', 'sensor.example', '12']],
]
for (const [item, labels] of cases) {
const snapshot = { connected: true, devices: [item], summary: { totalDevices: 1, onlineDevices: 1 },
environment: { temperature: 25.6, humidity: 58.8 }, controlParameters: {} }
const { html } = await render(deviceDetailLocation(item.deviceId), { haStatus: ref(snapshot) })
assert.equal((html.match(/<section class="device-card"/g) ?? []).length, 1, item.name)
for (const label of labels) assert.ok(html.includes(label), `${item.name}: ${label}`)
if (item.deviceId === 'generic') assert.ok(!html.includes('灯光色温调节'))
}
})
test('missing, offline and disconnected devices display honest explanatory states', async () => {
assert.ok((await render('/device-control/removed')).html.includes('未找到这个设备'))
const offline = { ...devices[1], online: false }
const snapshot = { connected: true, devices: [offline], summary: { totalDevices: 1, onlineDevices: 0 } }
const { html } = await render('/device-control/mattress', { haStatus: ref(snapshot) })
assert.ok(html.includes('设备回读显示离线'))
assert.ok(html.includes('右侧按摩部位'))
const disconnected = await render('/device-control/mattress', { haStatus: ref({ ...snapshot, connected: false, message: '连接失败' }) })
assert.ok(disconnected.html.includes('暂时无法读取设备'))
assert.ok(!disconnected.html.includes('未找到这个设备'))
const loading = await render('/device-control/mattress', { haStatus: ref(), loading: ref(true) })
assert.ok(loading.html.includes('正在读取设备详情'))
})
test('detail route safely round-trips unusual device IDs; unfinished constitution keeps scene start disabled', async () => {
const { router, html } = await render('/device-control', { tcmResult: ref({ hasCompleted: false }) })
const deviceId = '房间/灯 #1?'
const resolved = router.resolve(deviceDetailLocation(deviceId))
assert.equal(router.resolve(resolved.fullPath).params.deviceId, deviceId)
assert.match(html, /<button class="scene-btn sleep-btn" disabled/)
assert.ok(html.includes('去完成中医四诊'))
})
test('offline parameter details disable controls and do not present default values as current readings', async () => {
for (const item of [devices[0], devices[1], devices[2]]) {
const snapshot = { connected: true, devices: [{ ...item, online: false }], controlParameters: {} }
const { html } = await render(deviceDetailLocation(item.deviceId), { haStatus: ref(snapshot) })
const card = html.match(/<section class="device-card"[\s\S]*?<\/section>/)?.[0]
assert.ok(card)
for (const button of card.match(/<button\b[^>]*>/g) ?? []) assert.ok(button.includes('disabled'), button)
for (const select of card.match(/<select\b[^>]*>/g) ?? []) assert.ok(select.includes('disabled'), select)
for (const slider of card.match(/class="van-slider(?: |\")[^"\n]*"/g) ?? []) assert.ok(slider.includes('disabled'), slider)
assert.ok(!card.includes('<strong class="p-val">2700K</strong>'))
assert.ok(!card.includes('<strong class="p-val">20 分钟</strong>'))
assert.ok(!card.includes('<strong class="p-val">0 档</strong>'))
}
})
test('environment detail uses only that device metrics and hides offline or malformed readings', async () => {
const item = device('environment', '自己的环境检测仪', 'environment', 'EnvironmentSensor', [],
{ metrics: { temperature: '26.2', humidity: 'bad', co2: '900' } })
const snapshot = { connected: true, devices: [item], environment: { temperature: 99, humidity: 99 }, controlParameters: {} }
const { html } = await render(deviceDetailLocation(item.deviceId), { haStatus: ref(snapshot) })
assert.ok(html.includes('26.2')); assert.ok(html.includes('900')); assert.ok(!html.includes('<strong>99'))
item.online = false
const offline = await render(deviceDetailLocation(item.deviceId), { haStatus: ref(snapshot) })
assert.ok(!offline.html.includes('26.2')); assert.ok(!offline.html.includes('900'))
})
test('independent audio gateway still supports HA-offline playback while HA-only sleep mode is disabled', async () => {
const item = { ...devices[3], online: false, entities: devices[3].entities.map(value => ({ ...value, available: false })) }
const snapshot = { connected: true, devices: [item], controlParameters: {} }
const { html } = await render(deviceDetailLocation(item.deviceId), { haStatus: ref(snapshot),
musicTransition: ref({ running: true, hasVolume: true, volumePercent: 0 }) })
const card = html.match(/<section class="device-card"[\s\S]*?<\/section>/)?.[0]
assert.ok(card.includes('0% 静音'))
const playback = card.match(/<button\b[^>]*>[\s\S]*?播放测试音乐[\s\S]*?<\/button>/)?.[0]
assert.ok(playback); assert.ok(!playback.match(/^<button\b[^>]*disabled/))
assert.match(card, /<button class="mini-toggle" disabled/)
})