// 渲染真实 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 { 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, supported_color_modes: ['color_temp'], min_color_temp_kelvin: 2700, max_color_temp_kelvin: 6500, color_temp_kelvin: 3000, supported_features: 4, effect_list: ['阅读', '助眠'], effect: '阅读' })], { primaryControlEntityId: 'light.room' }), device('environment', '演示环境检测仪', 'environment', 'EnvironmentSensor'), 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(), 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, ...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(/]*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(/]*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(/]*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(!/[\s\S]*?<\/div>/)[0] assert.equal((group.match(/