660 lines
44 KiB
JavaScript
660 lines
44 KiB
JavaScript
// 无真实网络/家具的回归检查;使用现有 TypeScript 编译器和 Node 内置测试运行器。
|
|
import assert from 'node:assert/strict'
|
|
import { test } from 'node:test'
|
|
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
import vm from 'node:vm'
|
|
import { fileURLToPath } from 'node:url'
|
|
import ts from 'typescript'
|
|
|
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../src')
|
|
const deferred = () => {
|
|
let resolve, reject
|
|
const promise = new Promise((yes, no) => { resolve = yes; reject = no })
|
|
return { promise, resolve, reject }
|
|
}
|
|
const flush = async () => { for (let i = 0; i < 12; i++) await Promise.resolve() }
|
|
const control = (state, attributes = {}, available = true) => ({ entityId: 'fake', state, attributes, available })
|
|
const status = (controls = {}, marker = '') => ({ connected: true, message: marker, devices: [], controlParameters: controls })
|
|
const dashboard = () => ({ user: { id: 1 }, tcmResult: { hasCompleted: true } })
|
|
const sceneResult = (message, sceneCode = 'sleep') => ({ success: true, status: 'Succeeded', message, sceneCode, actions: [] })
|
|
|
|
function environment(overrides = {}) {
|
|
const intervals = new Map(), timeouts = new Map(), storage = new Map(), disposed = [], mounted = [], toasts = []
|
|
let id = 0
|
|
const watchers = []
|
|
const vue = {
|
|
ref: value => ({ value }), reactive: value => value,
|
|
computed: getter => ({ get value() { return typeof getter === 'function' ? getter() : getter.get() }, set value(value) { getter.set?.(value) } }),
|
|
watch: (getter, callback) => { watchers.push({ getter, callback, value: getter() }) },
|
|
onUnmounted: handler => disposed.push(handler), onMounted: handler => mounted.push(handler),
|
|
}
|
|
const healthApi = {
|
|
homeAssistantStatus: async () => status(), dashboard: async () => dashboard(),
|
|
musicTransitionStatus: async () => ({ running: false, hasVolume: false, volumePercent: 0 }),
|
|
lightTransitionStatus: async () => ({ running: false, brightnessPercent: 0 }),
|
|
...overrides,
|
|
}
|
|
const mocks = {
|
|
vue, 'vue-router': { useRouter: () => ({ push() {}, replace() {}, back() {} }) },
|
|
vant: {
|
|
showFailToast: message => toasts.push({ type: 'fail', message }),
|
|
showSuccessToast: message => toasts.push({ type: 'success', message }),
|
|
showConfirmDialog: overrides.confirmDialog ?? (async () => {}),
|
|
showLoadingToast: options => { const toast = { ...options, close() { this.closed = true } }; toasts.push(toast); return toast },
|
|
},
|
|
}
|
|
const cache = new Map()
|
|
function load(relative) {
|
|
const file = path.resolve(root, relative.endsWith('.ts') ? relative : relative + '.ts')
|
|
if (cache.has(file)) return cache.get(file).exports
|
|
if (file === path.join(root, 'services/health.ts')) return { healthApi }
|
|
const module = { exports: {} }; cache.set(file, module)
|
|
const source = ts.transpileModule(fs.readFileSync(file, 'utf8'), {
|
|
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS },
|
|
}).outputText
|
|
const context = {
|
|
exports: module.exports, module,
|
|
require: name => mocks[name] ?? load(path.relative(root, path.resolve(path.dirname(file), name))),
|
|
setInterval: callback => { const key = ++id; intervals.set(key, callback); return key },
|
|
clearInterval: key => intervals.delete(key),
|
|
setTimeout: callback => { const key = ++id; timeouts.set(key, callback); return key },
|
|
clearTimeout: key => timeouts.delete(key),
|
|
localStorage: { getItem: key => storage.get(key) ?? null, setItem: (key, value) => storage.set(key, value) },
|
|
window: { history: { length: 1 } }, Intl, Date,
|
|
}
|
|
vm.runInNewContext(source, context, { filename: file })
|
|
return module.exports
|
|
}
|
|
return { load, healthApi, intervals, timeouts, toasts, vue,
|
|
flushWatches: () => { for (const watcher of watchers) { const value = watcher.getter(); if (value !== watcher.value) { watcher.callback(value, watcher.value); watcher.value = value } } },
|
|
dispose: () => disposed.forEach(fn => fn()), mount: () => mounted.forEach(fn => fn()) }
|
|
}
|
|
|
|
test('parameter conversion rejects junk and unavailable data; light zero and hardware limits are respected', () => {
|
|
const env = environment(), helpers = env.load('devices/parameterState')
|
|
assert.equal(helpers.numberValue('31.1garbage'), undefined)
|
|
assert.equal(helpers.numberValue(''), undefined)
|
|
assert.equal(helpers.boolValue(control('on', {}, false)), undefined)
|
|
assert.equal(helpers.parameterIsAvailable({ quick: control('unknown') }, 'quick'), true)
|
|
assert.equal(helpers.parameterIsAvailable({}, 'quick'), false)
|
|
const { createDeviceParameterState, syncDeviceParameters } = env.load('devices/parameterRegistry')
|
|
const params = createDeviceParameterState()
|
|
syncDeviceParameters(params, { brightness: control('off'), color_temp: control('on', { color_temp_kelvin: 6500 }),
|
|
volume: control('on', { volume_level: .56 }), uv: control('on') })
|
|
assert.equal(params.lightBrightness, 0); assert.equal(params.lightColorTemp, 5000)
|
|
assert.equal(params.speakerVolume, 56)
|
|
assert.equal(params.dehumidifierUv, true)
|
|
syncDeviceParameters(params, { uv: control('unavailable', {}, false), volume: control('on', { volume_level: 'NaN' }) })
|
|
assert.equal(params.dehumidifierUv, true); assert.equal(params.speakerVolume, 56)
|
|
})
|
|
|
|
test('parameter availability is scoped to the actual device; missing numeric attributes are not default readings', () => {
|
|
const { parameterIsAvailable, parameterHasReading } = environment().load('devices/parameterState')
|
|
const lamp = device('lighting', 'Lighting', [entity('light.room', 'light', 'on')])
|
|
const controls = { brightness: { ...control('on'), entityId: 'light.room' } }
|
|
assert.equal(parameterIsAvailable(controls, 'brightness', lamp), true)
|
|
assert.equal(parameterHasReading(controls, 'brightness', lamp), false)
|
|
controls.brightness.attributes.brightness = 128
|
|
assert.equal(parameterHasReading(controls, 'brightness', lamp), true)
|
|
lamp.entities[0].available = false
|
|
assert.equal(parameterIsAvailable(controls, 'brightness', lamp), false)
|
|
lamp.entities[0].available = true; controls.brightness.entityId = 'light.foreign'
|
|
assert.equal(parameterIsAvailable(controls, 'brightness', lamp), false)
|
|
controls.brightness.entityId = 'light.room'; lamp.online = false
|
|
assert.equal(parameterIsAvailable(controls, 'brightness', lamp), false)
|
|
})
|
|
|
|
|
|
test('older transition response cannot replace newer volume; disposal cannot create a late timer', async () => {
|
|
const first = deferred(), second = deferred(); let reads = 0
|
|
const env = environment({ musicTransitionStatus: () => (++reads === 1 ? first : second).promise })
|
|
const params = env.load('devices/parameterRegistry').createDeviceParameterState()
|
|
const hook = env.load('composables/useDeviceTransitions').useDeviceTransitions(params, async () => {})
|
|
const a = hook.syncMusicTransitionStatus(), b = hook.syncMusicTransitionStatus()
|
|
second.resolve({ running: true, hasVolume: true, volumePercent: 46 }); await b
|
|
first.resolve({ running: true, hasVolume: true, volumePercent: 50 }); await a
|
|
assert.equal(params.speakerVolume, 46); assert.equal(env.intervals.size, 1)
|
|
env.dispose(); assert.equal(env.intervals.size, 0)
|
|
const late = deferred(); env.healthApi.musicTransitionStatus = () => late.promise
|
|
const request = hook.syncMusicTransitionStatus()
|
|
late.resolve({ running: true, hasVolume: true, volumePercent: 20 }); await request
|
|
assert.equal(params.speakerVolume, 46); assert.equal(env.intervals.size, 0)
|
|
})
|
|
|
|
test('out-of-order status reads preserve newer state and reported music overrides old HA volume', async () => {
|
|
const first = deferred(), second = deferred(); let reads = 0
|
|
const env = environment({ homeAssistantStatus: () => (++reads === 1 ? first : second).promise,
|
|
musicTransitionStatus: async () => ({ running: true, hasVolume: true, volumePercent: 46 }) })
|
|
const hook = env.load('composables/useDeviceControl').useDeviceControl()
|
|
const a = hook.loadStatus(), b = hook.loadStatus()
|
|
second.resolve(status({ volume: control('on', { volume_level: .7 }) }, 'new')); await b
|
|
first.resolve(status({}, 'old')); await a
|
|
assert.equal(hook.haStatus.value.message, 'new'); assert.equal(hook.localParams.speakerVolume, 46)
|
|
assert.equal(hook.loading.value, false)
|
|
env.dispose(); assert.equal(env.intervals.size, 0)
|
|
})
|
|
|
|
test('all-off can interrupt start; a late start result cannot overwrite off feedback or busy state', async () => {
|
|
const start = deferred(), off = deferred()
|
|
const env = environment({ executeSleepScene: () => start.promise, executeAllOffScene: () => off.promise })
|
|
const hook = env.load('composables/useDeviceControl').useDeviceControl()
|
|
await hook.loadStatus()
|
|
const a = hook.executeScene('sleep'), b = hook.executeScene('all-off')
|
|
assert.equal(hook.sceneRunning.value, 'all-off')
|
|
assert.equal(env.toasts.find(toast => toast.message === '正在启动舒睡场景…').forbidClick, false)
|
|
off.resolve(sceneResult('已关闭', 'all-off')); await b
|
|
start.resolve(sceneResult('已开启')); await a
|
|
assert.equal(hook.actionFeedback.value.message, '已关闭'); assert.equal(hook.sceneRunning.value, undefined)
|
|
env.dispose(); assert.equal(env.timeouts.size, 0)
|
|
})
|
|
|
|
test('failed parameter command restores last known reading even if refresh itself fails', async () => {
|
|
const env = environment({ homeAssistantStatus: async () => status({ uv: control('off') }),
|
|
setParameter: async () => ({ success: false, message: '失败' }) })
|
|
const hook = env.load('composables/useDeviceControl').useDeviceControl()
|
|
await hook.loadStatus(); hook.localParams.dehumidifierUv = true
|
|
env.healthApi.homeAssistantStatus = async () => { throw new Error('离线') }
|
|
assert.equal(await hook.applyParam('dehumidifier', 'uv', true), false)
|
|
assert.equal(hook.localParams.dehumidifierUv, false)
|
|
assert.equal(hook.actionFeedback.value.message, '失败')
|
|
env.dispose(); assert.equal(env.timeouts.size, 0)
|
|
})
|
|
|
|
test('disposing a page while a scene is pending prevents late refresh and success toast', async () => {
|
|
const pending = deferred(); let reads = 0
|
|
const env = environment({ homeAssistantStatus: async () => { reads++; return status() }, executeSleepScene: () => pending.promise })
|
|
const hook = env.load('composables/useDeviceControl').useDeviceControl()
|
|
await hook.loadStatus(); const scene = hook.executeScene('sleep')
|
|
env.dispose(); pending.resolve(sceneResult('迟到结果')); await scene; await flush()
|
|
assert.equal(reads, 1); assert.equal(env.intervals.size, 0); assert.equal(env.timeouts.size, 0)
|
|
assert.equal(env.toasts.some(toast => toast.message === '迟到结果'), false)
|
|
})
|
|
|
|
test('unavailable or unknown primary switches do not send commands; pending commands are not duplicated', async () => {
|
|
let calls = 0
|
|
const pending = deferred()
|
|
const env = environment({ switchDevice: () => { calls++; return pending.promise } })
|
|
const hook = env.load('composables/useDeviceControl').useDeviceControl()
|
|
await hook.loadStatus()
|
|
const item = device('fragrance', 'Fragrance', [entity('switch.fragrance', 'switch', 'unknown')],
|
|
{ primaryControlEntityId: 'switch.fragrance' })
|
|
await hook.toggleDevice(item); assert.equal(calls, 0)
|
|
item.entities[0].state = 'off'; item.entities[0].available = false
|
|
await hook.toggleDevice(item); assert.equal(calls, 0)
|
|
item.entities[0].available = true
|
|
const request = hook.toggleDevice(item)
|
|
await hook.toggleDevice(item); assert.equal(calls, 1)
|
|
pending.resolve({ success: true, deviceId: item.deviceId, entityId: 'switch.fragrance', state: 'on', message: '开启完成' })
|
|
await request; assert.equal(hook.switchingDeviceId.value, null)
|
|
env.dispose()
|
|
})
|
|
|
|
test('navigation closes a pending parameter toast and prevents a late result from refreshing or reporting success', async () => {
|
|
const pending = deferred(); let reads = 0, writes = 0
|
|
const env = environment({ homeAssistantStatus: async () => { reads++; return status() },
|
|
setParameter: () => { writes++; return pending.promise } })
|
|
const hook = env.load('composables/useDeviceControl').useDeviceControl()
|
|
await hook.loadStatus()
|
|
const request = hook.applyParam('speaker', 'volume', 20)
|
|
assert.equal(await hook.applyParam('speaker', 'volume', 30), false)
|
|
assert.equal(writes, 1)
|
|
const toast = env.toasts.find(item => item.message === '正在设置音箱音量…')
|
|
assert.equal(toast.closed, undefined)
|
|
env.dispose(); assert.equal(toast.closed, true)
|
|
pending.resolve({ success: true, message: '迟到的参数结果' })
|
|
assert.equal(await request, false)
|
|
assert.equal(reads, 1)
|
|
assert.equal(env.toasts.some(item => item.message === '迟到的参数结果'), false)
|
|
assert.equal(env.timeouts.size, 0)
|
|
})
|
|
|
|
|
|
const device = (moduleKey, category, entities = [], extra = {}) => ({
|
|
deviceId: 'device', name: '测试设备', manufacturer: '', model: '', moduleKey, category,
|
|
online: true, entities, ...extra,
|
|
})
|
|
const entity = (entityId, domain, state, attributes = {}, available = true) => ({ entityId, domain, state, attributes, available })
|
|
|
|
const standardLight = (id = 'light.strip', mode = 'color_temp', attributes = {}) => device('', 'Lighting', [entity(id, 'light', 'on', {
|
|
supported_color_modes: [mode], min_color_temp_kelvin: 2700, max_color_temp_kelvin: 6500, supported_features: 4,
|
|
brightness: 128, color_temp_kelvin: 3000, effect_list: ['阅读', '助眠'], effect: '阅读', ...attributes,
|
|
})], { deviceId: id, primaryControlEntityId: id })
|
|
|
|
test('configured strip defaults to its supported sleep effect without inventing a reported effect or sending on page load', () => {
|
|
const env = environment(), { useLightControls } = env.load('devices/lighting/useLightControls')
|
|
const item = standardLight('light.strip', 'color_temp', { effect: null, effect_list: ['阅读模式', '助眠模式'] })
|
|
item.moduleKey = 'sleep-light-strip'; item.defaultLightEffect = '助眠模式'
|
|
let writes = 0
|
|
const hook = useLightControls(() => item, () => false, async () => { writes++; return true })
|
|
assert.equal(hook.effectTarget.value, '助眠模式'); assert.equal(hook.defaultEffect.value, '助眠模式')
|
|
assert.equal(hook.capability.value.effect, undefined); assert.equal(writes, 0)
|
|
item.entities[0].attributes.effect = '阅读模式'
|
|
assert.equal(hook.effectTarget.value, '阅读模式'); assert.equal(hook.capability.value.effect, '阅读模式')
|
|
item.defaultLightEffect = '未声明模式'; item.entities[0].attributes.effect = null
|
|
assert.equal(hook.effectTarget.value, ''); assert.equal(hook.defaultEffect.value, '')
|
|
})
|
|
|
|
test('multiple lights use only their own fade status and continue polling when the original light has stopped', async () => {
|
|
let current = { entityId: 'light.primary', running: false, brightnessPercent: 0, mode: 'none', lights: [
|
|
{ entityId: 'light.primary', running: false, brightnessPercent: 0, mode: 'none' },
|
|
{ entityId: 'light.strip', running: true, brightnessPercent: 46, mode: 'sleep' },
|
|
] }
|
|
const env = environment({ lightTransitionStatus: async () => current })
|
|
const helpers = env.load('devices/lighting/transitions'), { deviceSummary } = env.load('devices/deviceSummary')
|
|
const strip = standardLight(); strip.moduleKey = 'sleep-light-strip'
|
|
assert.equal(helpers.lightTransitionForDevice(strip, current).brightnessPercent, 46)
|
|
assert.equal(helpers.anyLightTransitionRunning(current), true)
|
|
assert.equal(helpers.lightTransitionForDevice(standardLight('light.unrelated'), current), undefined)
|
|
const summary = deviceSummary(strip, { light: current })
|
|
assert.equal(summary.statusText, '助眠渐暗中'); assert.equal(summary.metricText, '亮度 46%')
|
|
let ended = 0
|
|
const params = env.load('devices/parameterRegistry').createDeviceParameterState()
|
|
params.lightBrightness = 10
|
|
const hook = env.load('composables/useDeviceTransitions').useDeviceTransitions(params, async () => { ended++ })
|
|
await hook.syncLightTransitionStatus()
|
|
assert.equal(env.intervals.size, 1); assert.equal(params.lightBrightness, 10)
|
|
current = { ...current, lights: current.lights.map(light => ({ ...light, running: false, brightnessPercent: 0 })) }
|
|
for (const tick of [...env.intervals.values()]) tick()
|
|
await flush(); assert.equal(env.intervals.size, 0); assert.equal(ended, 1)
|
|
env.dispose()
|
|
})
|
|
|
|
test('explicit entity markers and legacy single-light responses cannot leak into another lamp', () => {
|
|
const helpers = environment().load('devices/lighting/transitions')
|
|
const primary = standardLight('light.primary'); primary.moduleKey = 'lighting'
|
|
const strip = standardLight()
|
|
const legacy = { running: true, brightnessPercent: 50, mode: 'sleep' }
|
|
assert.equal(helpers.lightTransitionForDevice(primary, legacy), legacy)
|
|
assert.equal(helpers.lightTransitionForDevice(strip, legacy), undefined)
|
|
assert.equal(helpers.lightTransitionForDevice(primary, { ...legacy, entityId: 'light.strip' }), undefined)
|
|
})
|
|
|
|
test('a failed new-light fade is not reported as success merely because the original light finished', async () => {
|
|
let current = { entityId: 'light.primary', running: true, success: true, brightnessPercent: 1, mode: 'sleep', lights: [
|
|
{ entityId: 'light.primary', running: true, success: true, brightnessPercent: 1, mode: 'sleep' },
|
|
{ entityId: 'light.strip', running: true, success: true, brightnessPercent: 2, mode: 'sleep' },
|
|
] }
|
|
const primary = standardLight('light.primary'); primary.moduleKey = 'lighting'
|
|
const strip = standardLight('light.strip'); strip.moduleKey = 'sleep-light-strip'
|
|
const env = environment({ lightTransitionStatus: async () => current,
|
|
homeAssistantStatus: async () => ({ connected: true, devices: [primary, strip] }) })
|
|
const hook = env.load('composables/useDeviceControl').useDeviceControl(); await hook.loadStatus()
|
|
current = { ...current, running: false, message: '原灯渐暗完成', lights: [
|
|
{ entityId: 'light.primary', running: false, success: true, mode: 'sleep', message: '原灯渐暗完成' },
|
|
{ entityId: 'light.strip', running: false, success: false, mode: 'sleep', message: '灯带离线,渐变中断' },
|
|
] }
|
|
for (const tick of [...env.intervals.values()]) tick()
|
|
await flush(); await flush()
|
|
assert.equal(hook.actionFeedback.value.type, 'fail')
|
|
assert.equal(hook.actionFeedback.value.message, '灯带离线,渐变中断')
|
|
env.dispose()
|
|
})
|
|
|
|
test('an inventory without configured scene lights clears the previous fade and stops light requests', async () => {
|
|
const primary = standardLight('light.primary'); primary.moduleKey = 'lighting'
|
|
let devices = [primary], lightReads = 0
|
|
const env = environment({
|
|
homeAssistantStatus: async () => ({ connected: true, devices }),
|
|
lightTransitionStatus: async () => { lightReads++; return { running: true, brightnessPercent: 50, mode: 'sleep' } },
|
|
})
|
|
const hook = env.load('composables/useDeviceControl').useDeviceControl()
|
|
await hook.loadStatus()
|
|
assert.equal(lightReads, 1)
|
|
assert.equal(env.intervals.size, 1)
|
|
devices = []
|
|
await hook.loadStatus()
|
|
assert.equal(lightReads, 1)
|
|
assert.equal(hook.lightTransition.value, undefined)
|
|
assert.equal(env.intervals.size, 0)
|
|
env.dispose()
|
|
})
|
|
|
|
test('standard light capabilities are per entity, not per category or another lamps configured parameters', () => {
|
|
const { lightCapabilities } = environment().load('devices/lighting/capabilities')
|
|
const item = standardLight(), capability = lightCapabilities(item)
|
|
assert.equal(capability.available, true); assert.equal(capability.canDim, true); assert.equal(capability.brightnessPercent, 50)
|
|
assert.equal(capability.temperatureRange.min, 2700); assert.equal(capability.temperatureRange.max, 6500)
|
|
assert.equal(capability.colorTemperature, 3000); assert.equal(capability.effect, '阅读')
|
|
const simple = lightCapabilities(standardLight('light.simple', 'onoff', { effect_list: [], supported_features: 0 }))
|
|
assert.equal(simple.canDim, false); assert.equal(simple.temperatureRange, undefined); assert.equal(simple.effects.length, 0)
|
|
const dim = lightCapabilities(standardLight('light.dim', 'brightness'))
|
|
assert.equal(dim.canDim, true); assert.equal(dim.temperatureRange, undefined)
|
|
const rgb = lightCapabilities(standardLight('light.rgb', 'rgb'))
|
|
assert.equal(rgb.canDim, true); assert.equal(rgb.temperatureRange, undefined)
|
|
})
|
|
|
|
test('light unknown main state, foreign primary and missing capability cannot borrow online auxiliary data', () => {
|
|
const { lightCapabilities } = environment().load('devices/lighting/capabilities'), item = standardLight()
|
|
item.entities.push(entity('light.foreign', 'light', 'on', { supported_color_modes: ['color_temp'] }))
|
|
item.entities[0].state = 'unknown'; assert.equal(lightCapabilities(item).available, false)
|
|
assert.equal(lightCapabilities(item).brightnessPercent, undefined); assert.equal(lightCapabilities(item).colorTemperature, undefined)
|
|
item.entities[0].state = 'on'; item.entities[0].available = false
|
|
assert.equal(lightCapabilities(item).available, false)
|
|
item.primaryControlEntityId = 'light.missing'; assert.equal(lightCapabilities(item).entity, undefined)
|
|
const noCapabilities = standardLight(); noCapabilities.entities[0].attributes = {}
|
|
assert.equal(lightCapabilities(noCapabilities).canDim, false); assert.equal(lightCapabilities(noCapabilities).temperatureRange, undefined)
|
|
})
|
|
|
|
test('light ranges, feature flags and reported readings reject malformed or unsupported values', () => {
|
|
const { lightCapabilities } = environment().load('devices/lighting/capabilities')
|
|
for (const min of [undefined, 0, -1, 2700.5, 'garbage'])
|
|
assert.equal(lightCapabilities(standardLight('light.strip', 'color_temp', { min_color_temp_kelvin: min })).temperatureRange, undefined)
|
|
for (const max of [undefined, 2700, 2000, Infinity, 3000000000])
|
|
assert.equal(lightCapabilities(standardLight('light.strip', 'color_temp', { max_color_temp_kelvin: max })).temperatureRange, undefined)
|
|
for (const supported_features of [undefined, 0, 3, -1, 4.5, Infinity])
|
|
assert.equal(lightCapabilities(standardLight('light.strip', 'color_temp', { supported_features })).effects.length, 0)
|
|
const item = standardLight('light.strip', 'color_temp', { brightness: null, color_temp_kelvin: null, effect: '未声明模式' })
|
|
assert.equal(lightCapabilities(item).brightnessPercent, undefined); assert.equal(lightCapabilities(item).effect, undefined)
|
|
item.entities[0].state = 'off'; assert.equal(lightCapabilities(item).brightnessPercent, 0)
|
|
})
|
|
|
|
test('light drafts are instance-local and standard sends use exactly the displayed devices main entity', async () => {
|
|
const env = environment(), { useLightControls } = env.load('devices/lighting/useLightControls'), writes = []
|
|
let item = standardLight('light.first'), busy = false
|
|
const hook = useLightControls(() => item, () => busy, async (...args) => { writes.push(args); return true })
|
|
hook.brightnessTarget.value = 61; await hook.setBrightness()
|
|
assert.equal(writes[0][0], 'light.first'); assert.equal(writes[0][1], 'light_brightness'); assert.equal(writes[0][2], 61); assert.equal(writes[0][3], 'light.first')
|
|
hook.temperatureTarget.value = 6500; await hook.setTemperature()
|
|
assert.equal(writes[1][1], 'light_color_temperature'); assert.equal(writes[1][2], 6500)
|
|
hook.effectTarget.value = '助眠'; await hook.setEffect(); assert.equal(writes[2][1], 'light_effect')
|
|
hook.temperatureTarget.value = 2200; assert.equal(await hook.setTemperature(), false)
|
|
hook.effectTarget.value = '删除遥控器'; assert.equal(await hook.setEffect(), false)
|
|
busy = true; hook.brightnessTarget.value = 60; assert.equal(await hook.setBrightness(), false)
|
|
busy = false; hook.brightnessTarget.value = 87
|
|
item = standardLight('light.second', 'brightness', { brightness: 51 }); env.flushWatches()
|
|
assert.equal(hook.brightnessTarget.value, 20); assert.equal(hook.capability.value.temperatureRange, undefined)
|
|
await hook.setBrightness(); assert.equal(writes[3][0], 'light.second'); assert.equal(writes[3][3], 'light.second')
|
|
item.entities[0].state = 'unavailable'; assert.equal(await hook.setBrightness(), false)
|
|
assert.equal(writes.length, 4)
|
|
})
|
|
|
|
test('late light operation cannot reset another devices draft and failed operation restores true reading', async () => {
|
|
const env = environment(), { useLightControls } = env.load('devices/lighting/useLightControls'), pending = deferred()
|
|
let item = standardLight('light.first')
|
|
const hook = useLightControls(() => item, () => false, () => pending.promise)
|
|
hook.brightnessTarget.value = 70; const task = hook.setBrightness()
|
|
item = standardLight('light.second'); env.flushWatches(); hook.brightnessTarget.value = 83
|
|
pending.resolve(false); await task
|
|
assert.equal(hook.brightnessTarget.value, 83)
|
|
const failed = useLightControls(() => item, () => false, async () => false)
|
|
failed.brightnessTarget.value = 70; await failed.setBrightness()
|
|
assert.equal(failed.brightnessTarget.value, 50)
|
|
})
|
|
|
|
test('new light overview does not inherit the configured bedroom lamps transition runtime', () => {
|
|
const { deviceSummary } = environment().load('devices/deviceSummary'), item = standardLight()
|
|
const summary = deviceSummary(item, { light: { running: true, mode: 'wake', brightnessPercent: 77 } })
|
|
assert.equal(summary.statusText, '已点亮'); assert.equal(summary.metricText, '亮度 50% · 3000K')
|
|
assert.equal(summary.canToggle, true); assert.equal(summary.icon, 'lighting')
|
|
})
|
|
|
|
test('readonly generic button unknown state is not mislabeled as an unavailable action', () => {
|
|
const { entityStateLabel } = environment().load('devices/entityState')
|
|
assert.equal(entityStateLabel(entity('button.test', 'button', 'unknown')), '动作按钮(尚未触发)')
|
|
assert.equal(entityStateLabel(entity('button.test', 'button', 'unavailable', {}, false)), '不可用')
|
|
assert.equal(entityStateLabel(entity('sensor.test', 'sensor', 'unknown', {}, false)), '状态未回读')
|
|
})
|
|
|
|
const outletDevice = () => device('outlet', 'Outlet', [entity('switch.service', 'switch', 'on'), entity('switch.power', 'switch', 'off')],
|
|
{ primaryControlEntityId: 'switch.power' })
|
|
|
|
test('outlet summary and power capability use only the bound main switch, including a valid off state', () => {
|
|
const env = environment(), { deviceSummary } = env.load('devices/deviceSummary')
|
|
const { outletCapabilities } = env.load('devices/outlet/capabilities'), item = outletDevice()
|
|
assert.equal(outletCapabilities(item).available, true); assert.equal(outletCapabilities(item).powerOn, false)
|
|
assert.equal(deviceSummary(item).statusText, '已断电'); assert.equal(deviceSummary(item).canToggle, true)
|
|
assert.equal(deviceSummary(item).icon, 'outlet'); assert.equal(deviceSummary(item).categoryLabel, '智能插座')
|
|
item.entities[1].state = 'on'
|
|
assert.equal(deviceSummary(item).statusText, '已通电'); assert.equal(outletCapabilities(item).powerOn, true)
|
|
})
|
|
|
|
test('outlet unknown/offline/missing main entity never borrows a working auxiliary switch', () => {
|
|
const env = environment(), { outletCapabilities } = env.load('devices/outlet/capabilities')
|
|
const { deviceSummary } = env.load('devices/deviceSummary'), hook = env.load('composables/useDeviceControl').useDeviceControl()
|
|
for (const state of ['unknown', 'unavailable', '', 'idle', 'standby']) {
|
|
const item = outletDevice(); item.entities[1].state = state
|
|
assert.equal(outletCapabilities(item).available, false); assert.equal(outletCapabilities(item).powerOn, null)
|
|
assert.equal(deviceSummary(item).canToggle, false); assert.equal(hook.isDeviceControlAvailable(item), false)
|
|
}
|
|
const item = outletDevice(); item.entities[1].available = false
|
|
assert.equal(outletCapabilities(item).available, false)
|
|
item.entities[1].available = true; item.primaryControlEntityId = 'switch.foreign'
|
|
assert.equal(outletCapabilities(item).available, false)
|
|
item.primaryControlEntityId = 'switch.power'; item.moduleKey = ''
|
|
assert.equal(deviceSummary(item).canToggle, false); assert.equal(hook.isDeviceControlAvailable(item), false)
|
|
item.moduleKey = 'outlet'; item.online = false
|
|
assert.equal(outletCapabilities(item).powerOn, null); assert.equal(hook.isDeviceControlAvailable(item), false)
|
|
env.dispose()
|
|
})
|
|
|
|
test('outlet energy is standard read-only own telemetry, not an encoded power/voltage engineering field', () => {
|
|
const { outletCapabilities } = environment().load('devices/outlet/capabilities'), item = outletDevice()
|
|
item.entities.push(entity('sensor.encoded', 'sensor', '8472528'))
|
|
assert.equal(outletCapabilities(item).energy, undefined)
|
|
item.entities.push({ ...entity('sensor.energy', 'sensor', '0', { device_class: 'energy' }), unit: 'kWh' })
|
|
assert.equal(outletCapabilities(item).energy, 0)
|
|
for (const value of ['unknown', '-1', 'NaN', '20junk']) {
|
|
item.entities[3].state = value; assert.equal(outletCapabilities(item).energy, undefined)
|
|
}
|
|
item.entities[3].state = '1.25'; item.entities[3].available = false
|
|
assert.equal(outletCapabilities(item).energy, undefined)
|
|
item.entities[3].available = true; item.entities[3].unit = 'V'
|
|
assert.equal(outletCapabilities(item).energy, undefined)
|
|
})
|
|
|
|
test('manual outlet toggle sends exact main power entity and refuses unavailable controls', async () => {
|
|
const writes = [], item = outletDevice()
|
|
const env = environment({ switchDevice: async payload => {
|
|
writes.push(payload); const state = payload.turnOn ? 'on' : 'off'; item.entities[1].state = state
|
|
return { success: true, deviceId: payload.deviceId, entityId: payload.entityId, state, message: '已确认' }
|
|
} })
|
|
const hook = env.load('composables/useDeviceControl').useDeviceControl(); await hook.loadStatus()
|
|
await hook.toggleDevice(item); await hook.toggleDevice(item)
|
|
assert.equal(writes.length, 2); assert.equal(writes[0].turnOn, true); assert.equal(writes[1].turnOn, false)
|
|
for (const write of writes) assert.equal(write.entityId, 'switch.power')
|
|
item.entities[1].state = 'unknown'; await hook.toggleDevice(item)
|
|
assert.equal(writes.length, 2); env.dispose()
|
|
})
|
|
|
|
test('overview distinguishes a known off state from unavailable data and never adds unconnected climate controls', () => {
|
|
const { deviceSummary } = environment().load('devices/deviceSummary')
|
|
const bulb = device('lighting', 'Lighting', [entity('light.room', 'light', 'on', { brightness: 128 })], { primaryControlEntityId: 'light.room' })
|
|
assert.equal(deviceSummary(bulb).metricText, '亮度 50%')
|
|
assert.equal(deviceSummary(bulb).canToggle, true)
|
|
bulb.entities[0].state = 'off'
|
|
assert.equal(deviceSummary(bulb).powerOn, false)
|
|
bulb.entities[0].state = 'unknown'
|
|
assert.equal(deviceSummary(bulb).powerOn, null)
|
|
assert.equal(deviceSummary(bulb).canToggle, false)
|
|
bulb.online = false
|
|
assert.equal(deviceSummary(bulb).statusText, '设备离线')
|
|
const climate = device('', 'Climate', [entity('climate.room', 'climate', 'cool')], { primaryControlEntityId: 'climate.room' })
|
|
assert.equal(deviceSummary(climate).canToggle, false)
|
|
})
|
|
|
|
test('overview separates fragrance from its nightlight and does not infer retired mattress controls', () => {
|
|
const { deviceSummary } = environment().load('devices/deviceSummary')
|
|
const fragrance = device('fragrance', 'Fragrance', [entity('switch.fragrance', 'switch', 'off'), entity('light.night', 'light', 'on')],
|
|
{ primaryControlEntityId: 'switch.fragrance' })
|
|
assert.equal(deviceSummary(fragrance).powerOn, false)
|
|
assert.equal(deviceSummary(fragrance).statusText, '已关闭')
|
|
const mattress = device('mattress', 'Sleep', [entity('select.massage', 'select', '全身')])
|
|
assert.equal(deviceSummary(mattress).canToggle, false)
|
|
assert.equal(deviceSummary(mattress, { controls: { mattress_massage_right_part: { ...control('全身'), entityId: 'foreign' } } }).statusText, '已连接')
|
|
assert.equal(deviceSummary(mattress, { controls: { mattress_massage_right_part: { ...control('全身'), entityId: 'select.massage' } } }).statusText, '已连接')
|
|
assert.equal(deviceSummary(mattress, { controls: { mattress_massage_right_part: { ...control('unknown'), entityId: 'select.massage' } } }).statusText, '已连接')
|
|
})
|
|
|
|
test('overview uses latest music runtime volume even if HA still reports an old value or an offline speaker', () => {
|
|
const { deviceSummary } = environment().load('devices/deviceSummary')
|
|
const audio = device('audio', 'Media', [entity('media_player.room', 'media_player', 'idle', { volume_level: .7 })],
|
|
{ primaryControlEntityId: 'media_player.room' })
|
|
const music = { running: true, hasVolume: true, volumePercent: 46 }
|
|
assert.equal(deviceSummary(audio, { music }).metricText, '音量 46%')
|
|
audio.online = false; audio.entities[0].available = false
|
|
const summary = deviceSummary(audio, { music })
|
|
assert.equal(summary.statusText, '助眠播放中')
|
|
assert.equal(summary.powerOn, true)
|
|
assert.equal(summary.canToggle, true)
|
|
assert.equal(summary.audioControl, true)
|
|
})
|
|
|
|
test('module mapping never borrows same-category furniture; detail navigation preserves raw device IDs for router encoding', () => {
|
|
const { deviceModuleKey, deviceDetailLocation } = environment().load('devices/deviceKinds')
|
|
assert.equal(deviceModuleKey(device('', 'Lighting')), '')
|
|
assert.equal(deviceModuleKey(device(undefined, 'Lighting')), 'lighting')
|
|
assert.equal(deviceModuleKey(device('new-device', 'Lighting')), 'new-device')
|
|
const location = deviceDetailLocation('房间/灯 #1?')
|
|
assert.equal(location.name, 'device-detail')
|
|
assert.equal(location.params.deviceId, '房间/灯 #1?')
|
|
})
|
|
|
|
const climateAttributes = () => ({ hvac_modes: ['cool', 'heat', 'fan_only', 'off'], min_temp: 5, max_temp: 35,
|
|
temperature_unit: '°C',
|
|
target_temp_step: .5, fan_modes: ['自动', '低风', '中风', '高风'], current_temperature: 24.1,
|
|
temperature: 23, fan_mode: '低风', supported_features: 393 })
|
|
const climateDevice = (attributes = climateAttributes(), state = 'off', available = true) => device('climate', 'Climate',
|
|
[entity('climate.room', 'climate', state, attributes, available)], { primaryControlEntityId: 'climate.room' })
|
|
const curtainDevice = (attributes = { supported_features: 15 }, state = 'unavailable', available = false) => device('curtain', 'Curtain',
|
|
[entity('cover.room', 'cover', state, attributes, available)], { primaryControlEntityId: 'cover.room' })
|
|
|
|
test('climate reads real mode, fan and temperature capabilities instead of hardcoded hardware defaults', () => {
|
|
const { climateCapabilities } = environment().load('devices/climate/capabilities')
|
|
const item = climateDevice(), capability = climateCapabilities(item)
|
|
assert.equal(capability.available, true)
|
|
assert.deepEqual(Array.from(capability.modes), ['cool', 'heat', 'fan_only', 'off'])
|
|
assert.deepEqual(Array.from(capability.fans), ['自动', '低风', '中风', '高风'])
|
|
assert.equal(capability.range.min, 5); assert.equal(capability.range.max, 35); assert.equal(capability.range.step, .5)
|
|
assert.equal(capability.targetTemperature, 23); assert.equal(capability.currentTemperature, 24.1)
|
|
assert.equal(capability.fanMode, '低风'); assert.equal(capability.canSetTemperature, true)
|
|
assert.equal(capability.canSetFan, true); assert.equal(capability.supports(32), false)
|
|
const otherRange = climateCapabilities(climateDevice({ ...climateAttributes(), min_temp: 18, max_temp: 28, target_temp_step: 1 }))
|
|
assert.equal(otherRange.range.min, 18); assert.equal(otherRange.range.max, 28); assert.equal(otherRange.range.step, 1)
|
|
})
|
|
|
|
test('climate cannot fabricate missing target temperature, range, fan or mode capability', () => {
|
|
const { climateCapabilities } = environment().load('devices/climate/capabilities')
|
|
for (const bad of [undefined, null, 'NaN', '23garbage', 40]) {
|
|
const capability = climateCapabilities(climateDevice({ ...climateAttributes(), temperature: bad }))
|
|
assert.equal(capability.targetTemperature, undefined); assert.equal(capability.canSetTemperature, false)
|
|
}
|
|
for (const attribute of ['min_temp', 'max_temp', 'target_temp_step']) {
|
|
const attributes = climateAttributes(); delete attributes[attribute]
|
|
assert.equal(climateCapabilities(climateDevice(attributes)).range, undefined)
|
|
}
|
|
assert.equal(climateCapabilities(climateDevice({ ...climateAttributes(), target_temp_step: 0 })).range, undefined)
|
|
assert.equal(climateCapabilities(climateDevice({ ...climateAttributes(), supported_features: 1 })).canSetFan, false)
|
|
assert.equal(climateCapabilities(climateDevice({ ...climateAttributes(), fan_mode: 'invalid' })).fanMode, undefined)
|
|
assert.equal(climateCapabilities(climateDevice({ ...climateAttributes(), hvac_modes: ['off', null, '', 'invalid'] }, 'cool')).available, false)
|
|
const fahrenheit = climateCapabilities(climateDevice({ ...climateAttributes(), temperature_unit: '°F' }))
|
|
assert.equal(fahrenheit.temperatureUnit, '℉')
|
|
assert.equal(fahrenheit.canSetTemperature, false)
|
|
const unknownUnit = climateCapabilities(climateDevice({ ...climateAttributes(), temperature_unit: 'kelvin' }))
|
|
assert.equal(unknownUnit.targetTemperature, undefined); assert.equal(unknownUnit.currentTemperature, undefined)
|
|
assert.equal(unknownUnit.canSetTemperature, false)
|
|
const missingUnit = climateCapabilities(climateDevice({ ...climateAttributes(), temperature_unit: undefined }))
|
|
assert.equal(missingUnit.temperatureUnit, undefined); assert.equal(missingUnit.canSetTemperature, false)
|
|
assert.equal(missingUnit.currentTemperature, undefined); assert.equal(missingUnit.targetTemperature, undefined)
|
|
})
|
|
|
|
test('climate main control must be owned, available and bound; turn-on and turn-off use directional flags', () => {
|
|
const { climateCapabilities } = environment().load('devices/climate/capabilities')
|
|
const item = climateDevice()
|
|
assert.equal(climateCapabilities(item).canToggle, true)
|
|
item.entities[0].state = 'cool'; assert.equal(climateCapabilities(item).canToggle, true)
|
|
item.entities[0].attributes.supported_features = 257; assert.equal(climateCapabilities(item).canToggle, false)
|
|
item.entities[0].state = 'off'; assert.equal(climateCapabilities(item).canToggle, true)
|
|
item.entities[0].attributes.supported_features = 129; assert.equal(climateCapabilities(item).canToggle, false)
|
|
item.entities[0].attributes.supported_features = 393
|
|
item.entities[0].available = false; assert.equal(climateCapabilities(item).available, false)
|
|
item.entities.push(entity('sensor.online', 'sensor', '1')); assert.equal(climateCapabilities(item).canToggle, false)
|
|
item.entities[0].available = true; item.moduleKey = ''; assert.equal(climateCapabilities(item).available, false)
|
|
item.moduleKey = 'climate'; item.primaryControlEntityId = 'climate.foreign'
|
|
assert.equal(climateCapabilities(item).entity, undefined); assert.equal(climateCapabilities(item).canToggle, false)
|
|
})
|
|
|
|
test('curtain has dedicated feature-gated actions and never invents position for an unavailable main entity', () => {
|
|
const { curtainCapabilities } = environment().load('devices/curtain/capabilities')
|
|
const item = curtainDevice(), unavailable = curtainCapabilities(item)
|
|
assert.equal(unavailable.supports(4), true); assert.equal(unavailable.position, undefined)
|
|
for (const action of ['open', 'stop', 'close']) assert.equal(unavailable.canAction(action), false)
|
|
item.entities[0].available = true; item.entities[0].state = 'open'
|
|
const ready = curtainCapabilities(item)
|
|
for (const action of ['open', 'stop', 'close']) assert.equal(ready.canAction(action), true)
|
|
assert.equal(ready.canSetPosition, false)
|
|
item.entities[0].attributes.current_position = 0
|
|
assert.equal(curtainCapabilities(item).position, 0); assert.equal(curtainCapabilities(item).canSetPosition, true)
|
|
item.entities[0].attributes.supported_features = 1
|
|
const limited = curtainCapabilities(item)
|
|
assert.equal(limited.canAction('open'), true); assert.equal(limited.canAction('close'), false)
|
|
assert.equal(limited.canAction('stop'), false); assert.equal(limited.canSetPosition, false)
|
|
})
|
|
|
|
test('curtain rejects malformed position, unknown state, foreign entity and same-category unbound device', () => {
|
|
const { curtainCapabilities } = environment().load('devices/curtain/capabilities')
|
|
for (const current_position of [undefined, null, '', 'unknown', '50garbage', -1, 101])
|
|
assert.equal(curtainCapabilities(curtainDevice({ supported_features: 15, current_position }, 'open', true)).position, undefined)
|
|
const item = curtainDevice({ supported_features: 15, current_position: 50 }, 'unknown', true)
|
|
assert.equal(curtainCapabilities(item).available, false)
|
|
item.entities[0].state = 'open'; item.moduleKey = ''; assert.equal(curtainCapabilities(item).available, false)
|
|
item.moduleKey = 'curtain'; item.primaryControlEntityId = 'cover.foreign'
|
|
assert.equal(curtainCapabilities(item).entity, undefined)
|
|
})
|
|
|
|
test('new parameter synchronization preserves real readings and missing attributes are not readable', () => {
|
|
const env = environment(), { createDeviceParameterState, syncDeviceParameters } = env.load('devices/parameterRegistry')
|
|
const { parameterHasReading, parameterIsAvailable } = env.load('devices/parameterState')
|
|
const state = createDeviceParameterState()
|
|
assert.equal(state.climateTemperature, undefined); assert.equal(state.curtainPosition, undefined)
|
|
const controls = { climate_temperature: { ...control('off', climateAttributes()), entityId: 'climate.room' },
|
|
climate_mode: { ...control('cool'), entityId: 'climate.room' },
|
|
climate_fan_mode: { ...control('cool', { fan_mode: '低风' }), entityId: 'climate.room' },
|
|
curtain_position: { ...control('closed', { current_position: 0 }), entityId: 'cover.room' } }
|
|
syncDeviceParameters(state, controls)
|
|
assert.equal(state.climateTemperature, 23); assert.equal(state.climateMode, 'cool')
|
|
assert.equal(state.climateFanMode, '低风'); assert.equal(state.curtainPosition, 0)
|
|
assert.equal(parameterHasReading(controls, 'climate_temperature', climateDevice()), true)
|
|
assert.equal(parameterHasReading(controls, 'curtain_position', curtainDevice({}, 'closed', true)), true)
|
|
delete controls.climate_temperature.attributes.temperature
|
|
assert.equal(parameterHasReading(controls, 'climate_temperature', climateDevice()), false)
|
|
controls.climate_fan_mode.attributes = {}
|
|
assert.equal(parameterHasReading(controls, 'climate_fan_mode', climateDevice()), false)
|
|
controls.curtain_position.entityId = 'cover.foreign'
|
|
assert.equal(parameterIsAvailable(controls, 'curtain_position', curtainDevice({}, 'open', true)), false)
|
|
syncDeviceParameters(state, { climate_temperature: control('off', { temperature: 'garbage' }), curtain_position: control('closed', { current_position: -1 }) })
|
|
assert.equal(state.climateTemperature, 23); assert.equal(state.curtainPosition, 0)
|
|
})
|
|
|
|
test('new overview summaries use the main entity and curtain never exposes a generic power toggle', () => {
|
|
const { deviceSummary } = environment().load('devices/deviceSummary')
|
|
const climate = climateDevice()
|
|
assert.equal(deviceSummary(climate).statusText, '关闭'); assert.equal(deviceSummary(climate).metricText, '室温 24.1℃ · 目标 23℃')
|
|
assert.equal(deviceSummary(climate).canToggle, true)
|
|
climate.entities[0].available = false
|
|
assert.equal(deviceSummary(climate).statusText, '空调主控不可用'); assert.equal(deviceSummary(climate).canToggle, false)
|
|
const curtain = curtainDevice({ supported_features: 15, current_position: 55 }, 'opening', true)
|
|
assert.equal(deviceSummary(curtain).statusText, '正在打开'); assert.equal(deviceSummary(curtain).metricText, '开度 55%')
|
|
assert.equal(deviceSummary(curtain).canToggle, false)
|
|
delete curtain.entities[0].attributes.current_position
|
|
assert.equal(deviceSummary(curtain).metricText, '打开 · 停止 · 关闭')
|
|
curtain.entities[0].available = false
|
|
assert.equal(deviceSummary(curtain).statusText, '窗帘主控不可用')
|
|
})
|
|
|
|
test('climate generic switch accepts only bound directional capability and sends exact trusted entity', async () => {
|
|
const writes = [], env = environment({ switchDevice: async payload => { writes.push(payload); return { success: true, deviceId: payload.deviceId, entityId: payload.entityId, state: 'cool', message: '已发送' } } })
|
|
const hook = env.load('composables/useDeviceControl').useDeviceControl(); await hook.loadStatus()
|
|
const item = climateDevice()
|
|
assert.equal(hook.isDeviceControlAvailable(item), true)
|
|
await hook.toggleDevice(item)
|
|
assert.equal(writes.length, 1); assert.equal(writes[0].entityId, 'climate.room'); assert.equal(writes[0].turnOn, true)
|
|
item.entities[0].attributes.supported_features = 129
|
|
await hook.toggleDevice(item); assert.equal(writes.length, 1)
|
|
item.entities[0].attributes.supported_features = 393; item.moduleKey = ''
|
|
await hook.toggleDevice(item); assert.equal(writes.length, 1)
|
|
await hook.toggleDevice(curtainDevice({ supported_features: 15, current_position: 0 }, 'closed', true))
|
|
assert.equal(writes.length, 1)
|
|
env.dispose()
|
|
})
|