From 20467ca479d2a698d09a26dce3abbc11859b4605 Mon Sep 17 00:00:00 2001 From: fuenmao <980740792@qq.com> Date: Fri, 9 Oct 2026 18:06:21 +0800 Subject: [PATCH] 111 --- scripts/test-app-pages.mjs | 46 ++++- scripts/test-device-modules.mjs | 166 +++++++++++++++++- scripts/test-device-pages.mjs | 82 ++++++++- src/components/devices/GenericDeviceCard.vue | 3 +- src/components/devices/LightingCard.vue | 106 +++-------- src/components/devices/StandardLightCard.vue | 40 +++++ .../devices/StandardLightControls.vue | 42 +++++ src/composables/useDeviceControl.ts | 7 +- src/composables/useDeviceTransitions.ts | 10 +- src/devices/cardRegistry.ts | 8 +- src/devices/deviceSummary.ts | 24 ++- src/devices/entityState.ts | 9 + src/devices/lighting/capabilities.ts | 27 +++ src/devices/lighting/parameters.ts | 3 +- src/devices/lighting/transitions.ts | 16 ++ src/devices/lighting/useLightControls.ts | 50 ++++++ src/services/health.ts | 3 + src/styles/page-polish.css | 7 +- 18 files changed, 547 insertions(+), 102 deletions(-) create mode 100644 src/components/devices/StandardLightCard.vue create mode 100644 src/components/devices/StandardLightControls.vue create mode 100644 src/devices/entityState.ts create mode 100644 src/devices/lighting/capabilities.ts create mode 100644 src/devices/lighting/transitions.ts create mode 100644 src/devices/lighting/useLightControls.ts diff --git a/scripts/test-app-pages.mjs b/scripts/test-app-pages.mjs index 836852a..3dea8a7 100644 --- a/scripts/test-app-pages.mjs +++ b/scripts/test-app-pages.mjs @@ -5,7 +5,7 @@ import fs from 'node:fs' import path from 'node:path' import vm from 'node:vm' import { fileURLToPath } from 'node:url' -import { parse, compileScript } from 'vue/compiler-sfc' +import { parse, compileScript, compileStyle } from 'vue/compiler-sfc' import { createSSRApp, h, isRef } from 'vue' import { renderToString } from 'vue/server-renderer' import { createMemoryHistory, createRouter } from 'vue-router' @@ -183,3 +183,47 @@ test('auth and both questionnaires render safely; loading records never show an assert.ok(!html.includes('type="number"')); assert.ok(!html.includes(' { + const readStylesheet = relative => { + const filename = path.join(root, relative) + const result = compileStyle({ source: fs.readFileSync(filename, 'utf8'), filename, id: 'test-motion' }) + assert.deepEqual(result.errors, [], relative) + return result.rawResult.root + } + const stylesheet = readStylesheet('styles/page-polish.css') + const reducedMotion = stylesheet.nodes.find(node => node.type === 'atrule' + && node.name === 'media' && node.params === '(prefers-reduced-motion: reduce)') + assert.ok(reducedMotion, 'keep the reduced-motion preference supported') + const rule = reducedMotion.nodes.find(node => node.type === 'rule' + && ['*', '*::before', '*::after'].every(selector => node.selectors.includes(selector))) + assert.ok(rule, 'cover page indicators, device indicators and Vant loading animations') + const declarations = Object.fromEntries(rule.nodes.filter(node => node.type === 'decl') + .map(node => [node.prop, node])) + assert.equal(declarations.animation?.value, 'none') + assert.equal(declarations.animation?.important, true) + assert.equal(declarations['transition-duration']?.value, '0s') + assert.equal(declarations['transition-duration']?.important, true) + reducedMotion.walkDecls('animation-duration', declaration => { + assert.notEqual(declaration.value, '.01ms', 'never accelerate an infinite loading animation') + }) + + const loadingRule = stylesheet.nodes.find(node => node.type === 'rule' && node.selector === '.page-loading-mark') + assert.ok(loadingRule.nodes.some(node => node.prop === 'animation' && node.value === 'page-spin .9s linear infinite'), + 'normal motion keeps the existing loading speed') + const deviceStyles = readStylesheet('styles/device-control.css') + const deviceLoadingRule = deviceStyles.nodes.find(node => node.type === 'rule' && node.selector === '.control-page .operation-spinner') + assert.ok(deviceLoadingRule.nodes.some(node => node.prop === 'animation' && node.value === 'operation-spin 0.75s linear infinite')) +}) + +test('loading indicators keep visible status text even when their animation is stopped', async () => { + for (const [name, routePath, message] of [ + ['DashboardView', '/dashboard', '正在读取健康档案与舒睡设备…'], + ['ProfileView', '/profile', '正在读取您的健康档案…'], + ]) { + const html = await render(name, { loading: true }, routePath) + assert.ok(html.includes('page-loading-mark'), name) + assert.ok(html.includes(message), name) + assert.ok(html.includes('role="status"'), name) + } +}) diff --git a/scripts/test-device-modules.mjs b/scripts/test-device-modules.mjs index 146d64d..eb82f03 100644 --- a/scripts/test-device-modules.mjs +++ b/scripts/test-device-modules.mjs @@ -22,9 +22,11 @@ const sceneResult = (message, sceneCode = 'sleep') => ({ success: true, status: 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 getter() } }), + 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 = { @@ -64,7 +66,9 @@ function environment(overrides = {}) { vm.runInNewContext(source, context, { filename: file }) return module.exports } - return { load, healthApi, intervals, timeouts, toasts, vue, dispose: () => disposed.forEach(fn => fn()), mount: () => mounted.forEach(fn => fn()) } + 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', () => { @@ -240,6 +244,164 @@ const device = (moduleKey, category, 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 env = environment({ lightTransitionStatus: async () => current }) + 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('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' }) diff --git a/scripts/test-device-pages.mjs b/scripts/test-device-pages.mjs index ae12996..86e6dca 100644 --- a/scripts/test-device-pages.mjs +++ b/scripts/test-device-pages.mjs @@ -28,7 +28,9 @@ 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('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('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' }), @@ -213,6 +215,84 @@ test('outlet overview has a dedicated compact tile and trusted quick power switc for (const text of ['8472528', '充电保护、过载保护', '插座电源状态']) assert.ok(!html.includes(text), text) }) +const standardLight = (mode = 'color_temp', attributes = {}) => device('strip', '新灯带', '', 'Lighting', [reading('light.strip', 'light', 'off', { + supported_color_modes: [mode], min_color_temp_kelvin: 2700, max_color_temp_kelvin: 6500, supported_features: 4, + brightness: null, color_temp_kelvin: null, effect: null, effect_list: ['阅读模式', '助眠模式'], ...attributes, +}), { ...reading('number.packed', 'number', '842138120'), name: '内部保护设置' }], { primaryControlEntityId: 'light.strip' }) +const lightSnapshot = item => ({ connected: true, devices: [item], summary: { totalDevices: 1, onlineDevices: item.online ? 1 : 0 }, controlParameters: {} }) + +test('configured sleep strip preselects its default and displays its own fade without copying wake or massage features', async () => { + const item = standardLight(); item.moduleKey = 'sleep-light-strip'; item.defaultLightEffect = '助眠模式' + item.entities[0].state = 'on'; item.entities[0].attributes.brightness = 128 + const transition = { entityId: 'light.room', running: true, brightnessPercent: 11, mode: 'wake', lights: [ + { entityId: 'light.room', running: true, brightnessPercent: 11, mode: 'wake' }, + { entityId: 'light.strip', running: true, brightnessPercent: 46, mode: 'sleep' }, + ] } + const { html } = await render('/device-control/strip', { haStatus: ref(lightSnapshot(item)), lightTransition: ref(transition) }) + const card = html.match(/
/)[0] + assert.ok(card.includes('助眠渐暗中 · 46%')); assert.match(card, /]*>46%<\/strong>/) + assert.match(card, /