From db0c578890f37570a8020bd7cee6fdaf1c02f849 Mon Sep 17 00:00:00 2001 From: fuenmao <980740792@qq.com> Date: Wed, 11 Nov 2026 16:12:02 +0800 Subject: [PATCH] 1111 --- scripts/test-app-pages.mjs | 72 ++++- scripts/test-device-modules.mjs | 60 ++--- scripts/test-device-pages.mjs | 29 +-- src/components/devices/LightingCard.vue | 2 +- src/components/devices/MattressCard.vue | 155 ----------- src/composables/useDeviceControl.ts | 32 +-- src/composables/useDeviceTransitions.ts | 8 +- src/devices/cardRegistry.ts | 3 +- src/devices/deviceKinds.ts | 2 +- src/devices/deviceSummary.ts | 11 - src/devices/mattress/parameters.ts | 36 --- src/devices/mattress/useMattressControls.ts | 27 -- .../mattress/useMattressPreferences.ts | 47 ---- src/devices/parameterRegistry.ts | 7 +- src/router.ts | 2 + src/services/health.ts | 20 +- src/services/ib43.ts | 65 +++++ src/styles/device-control.css | 35 --- src/styles/page-polish.css | 2 +- src/views/DashboardView.vue | 23 +- src/views/SleepDataView.vue | 245 ++++++++++++++++++ src/views/TcmAssessmentView.vue | 4 +- vite.config.ts | 3 +- 23 files changed, 464 insertions(+), 426 deletions(-) delete mode 100644 src/components/devices/MattressCard.vue delete mode 100644 src/devices/mattress/parameters.ts delete mode 100644 src/devices/mattress/useMattressControls.ts delete mode 100644 src/devices/mattress/useMattressPreferences.ts create mode 100644 src/services/ib43.ts create mode 100644 src/views/SleepDataView.vue diff --git a/scripts/test-app-pages.mjs b/scripts/test-app-pages.mjs index 3dea8a7..83924a1 100644 --- a/scripts/test-app-pages.mjs +++ b/scripts/test-app-pages.mjs @@ -23,7 +23,7 @@ const dashboardRecord = (name = '演示用户') => ({ user: { id: 1, displayName profile: {}, hasCompletedAssessment: false, tcmResult: { hasCompleted: true, constitutionName: '平和质' } }) const deviceStatus = () => ({ connected: true, sceneDevices: { available: 3 } }) -function environment(overrides = {}) { +function environment(overrides = {}, ib43Get) { const disposed = [], toasts = [], navigations = [], tokenWrites = [], mounted = [] const router = { replace: location => navigations.push(location), push: location => navigations.push(location) } const vue = { defineComponent: options => options, ref: value => ({ value }), reactive: value => value, @@ -43,6 +43,7 @@ function environment(overrides = {}) { const file = path.resolve(root, /\.(vue|ts)$/.test(relative) ? relative : relative + '.ts') const normalized = file.replaceAll('\\', '/') if (normalized.endsWith('services/health.ts')) return { healthApi } + if (normalized.endsWith('services/http.ts') && ib43Get) return { default: { get: ib43Get } } if (normalized.endsWith('services/auth.ts')) return { saveTokens: (...args) => tokenWrites.push(args), clearTokens: () => tokenWrites.push('clear') } if (file.endsWith('AppTabBar.vue')) return { default: {} } if (file.endsWith('useDeviceScenes.ts')) return { useDeviceScenes: options => { @@ -158,14 +159,79 @@ async function render(name, values = {}, routePath = '/dashboard') { test('home renders a prominent scene, collapsed presets, a real profile entry and the required navigation order', async () => { const record = { ...dashboardRecord(), bmi: 31.1, sceneConfig: { targetHumidity: 55, fanLevel: '1挡', lightBrightnessPercent: 50, lightColorTemperatureKelvin: 2700 } } - const html = await render('DashboardView', { loading: false, dashboard: record, homeAssistant: deviceStatus() }) - for (const value of ['我的舒睡空间', '一键开启舒睡', '查看舒睡设备', '查看体质启动预设', '打开我的健康档案']) assert.ok(html.includes(value), value) + const html = await render('DashboardView', { loading: false, dashboard: record, homeAssistant: { + ...deviceStatus(), scenePresets: [{ deviceName: '米家除湿机', entityId: 'humidifier.keep', action: '恒湿', detail: '目标 55%', available: true }], + } }) + for (const value of ['我的舒睡空间', '一键开启舒睡', '查看舒睡设备', '睡眠数据', '查看体质启动预设', '打开我的健康档案']) assert.ok(html.includes(value), value) assert.match(html, /
/) + assert.match(html, /米家除湿机/) + assert.doesNotMatch(html, /助眠灯光|2700K/) assert.ok(html.indexOf('首页') < html.indexOf('中医四诊')) assert.ok(html.indexOf('中医四诊') < html.indexOf('健康评估')) assert.ok(html.indexOf('健康评估') < html.indexOf('我的')) }) +const sleepBed = { id: 'left-bed', bedName: '卧室左侧', side: 'left', simulation: true, endedAtMs: null } +const rightBed = { id: 'right-bed', bedName: '卧室右侧', side: 'right', simulation: false, endedAtMs: null } + +test('sleep data renders stale readings and missing fields separately, with the same time for UTC strings and history milliseconds', async () => { + const instant = Date.parse('2026-11-11T01:55:21Z') + const readings = { + heartRate: { value: 72, receivedAt: '2026-11-11 01:55:21', freshness: 'stale' }, + respiration: { value: null, receivedAt: '2026-11-11 01:55:21', freshness: 'recent' }, + snoring: { value: 0, receivedAt: '2026-11-11 01:55:21', freshness: 'stale' }, + } + const html = await render('SleepDataView', { + bindingsLoading: false, beds: [sleepBed], bedId: sleepBed.id, + current: { bed: sleepBed, active: true, transport: 'connected', readings }, + history: [{ id: '1', receivedAtMs: String(instant), readings }], + }, '/sleep-data') + assert.match(html, /模拟设备/) + assert.match(html, /数据服务已连接/) + assert.match(html, /数据已过期/) + assert.match(html, /暂无有效读数/) + assert.match(html, /暂无上报/) + assert.match(html, /未打鼾/) + assert.doesNotMatch(html, /离床/) + const { formatIb43Time } = await server.ssrLoadModule('/src/services/ib43.ts') + assert.equal(formatIb43Time(readings.heartRate.receivedAt), formatIb43Time(String(instant))) + const empty = await render('SleepDataView', { bindingsLoading: false }, '/sleep-data') + assert.match(empty, /暂无绑定床位/) + assert.doesNotMatch(empty, /最近读数|历史记录/) +}) + +test('sleep data discards late results from a previous bed and keeps the time range and cursor when paging', async () => { + const oldCurrent = deferred(), oldHistory = deferred(), historyRequests = [] + const cursor = '9007199254740993' + const env = environment({}, async (url, options) => { + if (url.endsWith('/bindings')) return [sleepBed, rightBed] + if (url.includes('/left-bed/')) return url.endsWith('/current') ? oldCurrent.promise : oldHistory.promise + if (url.endsWith('/right-bed/current')) return { bed: rightBed, readings: {} } + if (url.endsWith('/right-bed/history')) { + historyRequests.push(options.params) + return options.params.beforeId == null ? { items: [{ id: 'right-1' }], nextBeforeId: cursor } : + { items: [{ id: 'right-2' }], nextBeforeId: null } + } + throw new Error(`Unexpected request: ${url}`) + }) + const page = env.page('SleepDataView') + await page.loadBindings() + assert.equal(page.bedId.value, 'left-bed') + page.bedId.value = 'right-bed' + page.loadBed() + await new Promise(resolve => setImmediate(resolve)) + oldCurrent.resolve({ bed: sleepBed }); oldHistory.resolve({ items: [{ id: 'left-1' }] }) + await new Promise(resolve => setImmediate(resolve)) + assert.equal(page.current.value.bed.id, 'right-bed') + assert.equal(page.history.value[0].id, 'right-1') + await page.loadHistory(true) + assert.equal(historyRequests[1].beforeId, cursor) + assert.equal(historyRequests[1].fromMs, historyRequests[0].fromMs) + assert.equal(historyRequests[1].toMs, historyRequests[0].toMs) + assert.equal(page.history.value[1].id, 'right-2') + assert.equal(page.nextBeforeId.value, undefined) +}) + test('error pages render retry controls without mistaken missing-assessment or device actions', async () => { for (const [name, path] of [['DashboardView', '/dashboard'], ['ProfileView', '/profile']]) { const html = await render(name, { loading: false, loadError: '测试连接失败' }, path) diff --git a/scripts/test-device-modules.mjs b/scripts/test-device-modules.mjs index eb82f03..17fb52f 100644 --- a/scripts/test-device-modules.mjs +++ b/scripts/test-device-modules.mjs @@ -81,9 +81,9 @@ test('parameter conversion rejects junk and unavailable data; light zero and har const { createDeviceParameterState, syncDeviceParameters } = env.load('devices/parameterRegistry') const params = createDeviceParameterState() syncDeviceParameters(params, { brightness: control('off'), color_temp: control('on', { color_temp_kelvin: 6500 }), - mattress_left_heating: control('4'), volume: control('on', { volume_level: .56 }), fragrance_auto_light: control('on') }) + volume: control('on', { volume_level: .56 }), fragrance_auto_light: control('on') }) assert.equal(params.lightBrightness, 0); assert.equal(params.lightColorTemp, 5000) - assert.equal(params.mattressLeftHeating, 4); assert.equal(params.speakerVolume, 56) + assert.equal(params.speakerVolume, 56) assert.equal(params.fragranceAutoLight, true) syncDeviceParameters(params, { fragrance_auto_light: control('unavailable', {}, false), volume: control('on', { volume_level: 'NaN' }) }) assert.equal(params.fragranceAutoLight, true); assert.equal(params.speakerVolume, 56) @@ -105,20 +105,6 @@ test('parameter availability is scoped to the actual device; missing numeric att assert.equal(parameterIsAvailable(controls, 'brightness', lamp), false) }) -test('mattress per-user preferences remain separate from the one physically running side', () => { - const env = environment(), params = env.load('devices/parameterRegistry').createDeviceParameterState() - const user = env.vue.ref(1), hook = env.load('devices/mattress/useMattressPreferences').useMattressPreferences(params, user, () => 'mattress') - hook.syncModes({ mattress_left_mode: control('智能适配'), mattress_right_mode: control('无') }) - assert.equal(params.mattressLeftMode, '智能适配'); assert.equal(params.mattressRightMode, '智能适配') - assert.equal(hook.mattressActiveSide.value, 'left') - hook.commitMode('left', '瑜伽助眠'); hook.commitMode('right', '智能腰托') - hook.syncModes({ mattress_left_mode: control('无'), mattress_right_mode: control('智能腰托') }) - assert.equal(params.mattressLeftMode, '瑜伽助眠'); assert.equal(hook.mattressActiveSide.value, 'right') - hook.syncModes({ mattress_left_mode: control('智能适配'), mattress_right_mode: control('智能腰托') }) - assert.equal(hook.mattressActiveSide.value, 'right') - user.value = 2; hook.syncModes({}) - assert.equal(params.mattressLeftMode, '智能适配') -}) test('older transition response cannot replace newer volume; disposal cannot create a late timer', async () => { const first = deferred(), second = deferred(); let reads = 0 @@ -223,20 +209,6 @@ test('navigation closes a pending parameter toast and prevents a late result fro assert.equal(env.timeouts.size, 0) }) -test('mattress confirmation cannot act after leaving the page or switching to another mattress', async () => { - const confirmation = deferred(), env = environment({ confirmDialog: () => confirmation.promise }) - const selected = env.vue.ref(device('mattress', 'Sleep', [], { deviceId: 'first' })) - let writes = 0 - const hook = env.load('devices/mattress/useMattressControls').useMattressControls(selected, - async () => { writes++; return true }, async () => {}, () => {}) - const request = hook.setMattressSelect('mattress_massage_right_part', { target: { value: '全身' } }) - selected.value = undefined; confirmation.resolve(); await request - assert.equal(writes, 0) - selected.value = device('mattress', 'Sleep', [], { deviceId: 'first' }) - env.dispose() - await hook.setMattressSelect('mattress_massage_right_part', { target: { value: '全身' } }) - assert.equal(writes, 0) -}) const device = (moduleKey, category, entities = [], extra = {}) => ({ deviceId: 'device', name: '测试设备', manufacturer: '', model: '', moduleKey, category, @@ -303,7 +275,10 @@ test('a failed new-light fade is not reported as success merely because the orig { 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 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: '原灯渐暗完成' }, @@ -316,6 +291,25 @@ test('a failed new-light fade is not reported as success merely because the orig 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) @@ -479,7 +473,7 @@ test('overview distinguishes a known off state from unavailable data and never a assert.equal(deviceSummary(climate).canToggle, false) }) -test('overview separates fragrance from its nightlight and only uses mattress readings belonging to that device', () => { +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' }) @@ -488,7 +482,7 @@ test('overview separates fragrance from its nightlight and only uses mattress re 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('全身'), entityId: 'select.massage' } } }).statusText, '已连接') assert.equal(deviceSummary(mattress, { controls: { mattress_massage_right_part: { ...control('unknown'), entityId: 'select.massage' } } }).statusText, '已连接') }) diff --git a/scripts/test-device-pages.mjs b/scripts/test-device-pages.mjs index 86e6dca..1671805 100644 --- a/scripts/test-device-pages.mjs +++ b/scripts/test-device-pages.mjs @@ -17,7 +17,6 @@ const { default: Overview } = await server.ssrLoadModule('/src/views/DeviceContr 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') @@ -31,7 +30,7 @@ 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('mattress', '演示智能床垫', 'mattress', 'Sleep'), + 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' }), ] @@ -50,8 +49,7 @@ async function render(location = '/device-control', overrides = {}) { 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, + localParams: createDeviceParameterState(), isDeviceTurnedOn: value => Boolean(reportedPower(primaryDeviceEntity(value))), isDeviceControlAvailable: value => primaryDeviceEntity(value)?.domain === 'climate' ? climateCapabilities(value).canToggle : Boolean(value?.online && reportedPower(primaryDeviceEntity(value)) !== null && @@ -60,7 +58,7 @@ async function render(location = '/device-control', overrides = {}) { 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, + ...overrides, } await router.push(location); await router.isReady() const app = createSSRApp({ render: () => h(RouterView) }) @@ -171,7 +169,7 @@ test('unbound devices in new categories remain generic and cannot borrow bound c test('overview has compact tiles and room tabs without detailed sliders or mattress controls', async () => { const { html } = await render() assert.equal((html.match(/
/g) ?? []) { @@ -288,16 +286,15 @@ test('offline main light disables standard controls even when an auxiliary entit test('configured scene light composes shared controls with its own fade and wake extensions', async () => { const { html } = await render('/device-control/lamp') - for (const text of ['灯光亮度', '灯光色温调节', '灯光模式', '开始助眠渐暗', '晨起唤醒', '灯灭停止按摩']) assert.ok(html.includes(text), text) + for (const text of ['灯光亮度', '灯光色温调节', '灯光模式', '开始助眠渐暗', '晨起唤醒']) assert.ok(html.includes(text), text) assert.equal((html.match(/
{ +test('retired mattress detail link has no controls', async () => { const { html } = await render('/device-control/mattress') - assert.equal((html.match(/
{ @@ -330,13 +327,13 @@ test('missing, offline and disconnected devices display honest explanatory state 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) }) + const { html } = await render('/device-control/environment', { 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(html.includes('温度')) + const disconnected = await render('/device-control/environment', { 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) }) + const loading = await render('/device-control/environment', { haStatus: ref(), loading: ref(true) }) assert.ok(loading.html.includes('正在读取设备详情')) }) @@ -350,7 +347,7 @@ test('detail route safely round-trips unusual device IDs; unfinished constitutio }) 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]]) { + for (const item of [devices[0], 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(/
/)?.[0] diff --git a/src/components/devices/LightingCard.vue b/src/components/devices/LightingCard.vue index 5ba1ff5..bd5406c 100644 --- a/src/components/devices/LightingCard.vue +++ b/src/components/devices/LightingCard.vue @@ -13,7 +13,7 @@ const transition = computed(() => lightTransitionForDevice(props.device, lightTr