This commit is contained in:
fuenmao
2026-11-11 16:12:02 +08:00
parent 20467ca479
commit db0c578890
23 changed files with 464 additions and 426 deletions
+69 -3
View File
@@ -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, /<details class="startup-preset-details">/)
assert.match(html, /米家除湿机/)
assert.doesNotMatch(html, /助眠灯光|2700K/)
assert.ok(html.indexOf('首页</span>') < html.indexOf('中医四诊</span>'))
assert.ok(html.indexOf('中医四诊</span>') < html.indexOf('健康评估</span>'))
assert.ok(html.indexOf('健康评估</span>') < html.indexOf('我的</span>'))
})
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)
+27 -33
View File
@@ -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, '已连接')
})
+13 -16
View File
@@ -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(/<article class="device-overview-tile/g) ?? []).length, 4)
for (const value of ['一键启动全屋舒睡模式', '一键关闭设备', '全屋', '睡眠空间', '音量 46%', '演示智能床垫']) assert.ok(html.includes(value), value)
for (const value of ['一键启动全屋舒睡模式', '一键关闭设备', '全屋', '睡眠空间', '音量 46%', '演示环境检测仪']) assert.ok(html.includes(value), value)
for (const value of ['左侧加热档位', '按摩力度', '灯光色温调节', 'van-slider']) assert.ok(!html.includes(value), value)
// 快捷开关与进入详情按钮是同级元素,不会发生嵌套按钮或点击开关误进详情。
for (const tile of html.match(/<article class="device-overview-tile[\s\S]*?<\/article>/g) ?? []) {
@@ -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(/<section class="device-card"/g) ?? []).length, 1)
assert.ok(!html.includes('不自动加入舒睡或唤醒场景'))
})
test('mattress detail retains full controls and never renders other devices or scene-wide actions', async () => {
test('retired mattress detail link has no controls', async () => {
const { html } = await render('/device-control/mattress')
assert.equal((html.match(/<section class="device-card"/g) ?? []).length, 1)
for (const value of ['演示智能床垫', '全部设备', '左侧加热档位', '右侧加热档位', '左侧承托模式', '右侧承托模式', '左肩软硬度', '右腿软硬度', '左侧按摩部位', '右侧按摩部位', '按摩力度']) assert.ok(html.includes(value), value)
for (const value of ['演示卧室灯', '演示香氛机', '一键启动全屋舒睡模式', 'device-overview-tile']) assert.ok(!html.includes(value), value)
assert.ok(html.includes('未找到这个设备'))
for (const value of ['左侧加热档位', '按摩力度', '左肩软硬度']) assert.ok(!html.includes(value), value)
})
test('fragrance detail retains separate nightlight and unavailable quick-release feedback', async () => {
@@ -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(/<section class="device-card"[\s\S]*?<\/section>/)?.[0]