From fda104041a5a902c8e0c003263753fd88cc188b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B4=94=E9=B2=81=E6=AD=A3?= <13780741225@163.com> Date: Thu, 10 Sep 2026 10:43:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20Mock=20=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=B1=82=E4=B8=8E=E4=BE=9D=E8=B5=96=E5=AE=B9=E5=99=A8?= =?UTF-8?q?=EF=BC=88=E5=90=AB=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../placeholder/ringhealth/AppContainer.kt | 20 ++++ .../ringhealth/RingHealthApplication.kt | 14 ++- .../ringhealth/data/mock/MockDataProvider.kt | 110 ++++++++++++++++++ .../data/mock/MockHealthDataRepository.kt | 38 ++++++ .../data/mock/MockRingDeviceRepository.kt | 39 +++++++ .../data/mock/MockDataProviderTest.kt | 64 ++++++++++ 6 files changed, 283 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/placeholder/ringhealth/AppContainer.kt create mode 100644 app/src/main/java/com/placeholder/ringhealth/data/mock/MockDataProvider.kt create mode 100644 app/src/main/java/com/placeholder/ringhealth/data/mock/MockHealthDataRepository.kt create mode 100644 app/src/main/java/com/placeholder/ringhealth/data/mock/MockRingDeviceRepository.kt create mode 100644 app/src/test/java/com/placeholder/ringhealth/data/mock/MockDataProviderTest.kt diff --git a/app/src/main/java/com/placeholder/ringhealth/AppContainer.kt b/app/src/main/java/com/placeholder/ringhealth/AppContainer.kt new file mode 100644 index 0000000..5015137 --- /dev/null +++ b/app/src/main/java/com/placeholder/ringhealth/AppContainer.kt @@ -0,0 +1,20 @@ +package com.placeholder.ringhealth + +import com.placeholder.ringhealth.data.mock.MockHealthDataRepository +import com.placeholder.ringhealth.data.mock.MockRingDeviceRepository +import com.placeholder.ringhealth.domain.repository.HealthDataRepository +import com.placeholder.ringhealth.domain.repository.RingDeviceRepository + +/** + * 手动依赖注入容器。M2 接入 SDK 时只需在这里把 Mock 实现换成真实实现。 + */ +interface AppContainer { + val healthDataRepository: HealthDataRepository + val ringDeviceRepository: RingDeviceRepository +} + +/** 默认容器:阶段一全部使用 Mock 数据。 */ +class DefaultAppContainer : AppContainer { + override val healthDataRepository: HealthDataRepository = MockHealthDataRepository() + override val ringDeviceRepository: RingDeviceRepository = MockRingDeviceRepository() +} diff --git a/app/src/main/java/com/placeholder/ringhealth/RingHealthApplication.kt b/app/src/main/java/com/placeholder/ringhealth/RingHealthApplication.kt index 87a57be..2d44fba 100644 --- a/app/src/main/java/com/placeholder/ringhealth/RingHealthApplication.kt +++ b/app/src/main/java/com/placeholder/ringhealth/RingHealthApplication.kt @@ -3,6 +3,16 @@ package com.placeholder.ringhealth import android.app.Application /** - * 应用入口。同时作为手动依赖注入的组合根(M1 阶段只持有 Mock 容器)。 + * 应用入口,同时作为手动依赖注入的组合根。 */ -class RingHealthApplication : Application() +class RingHealthApplication : Application() { + + /** 全局依赖容器。 */ + lateinit var container: AppContainer + private set + + override fun onCreate() { + super.onCreate() + container = DefaultAppContainer() + } +} diff --git a/app/src/main/java/com/placeholder/ringhealth/data/mock/MockDataProvider.kt b/app/src/main/java/com/placeholder/ringhealth/data/mock/MockDataProvider.kt new file mode 100644 index 0000000..911d5da --- /dev/null +++ b/app/src/main/java/com/placeholder/ringhealth/data/mock/MockDataProvider.kt @@ -0,0 +1,110 @@ +package com.placeholder.ringhealth.data.mock + +import com.placeholder.ringhealth.domain.model.DateRange +import com.placeholder.ringhealth.domain.model.DeviceFunctionSetting +import com.placeholder.ringhealth.domain.model.HealthSummary +import com.placeholder.ringhealth.domain.model.MetricPoint +import com.placeholder.ringhealth.domain.model.MetricType +import com.placeholder.ringhealth.domain.model.RingDeviceState +import com.placeholder.ringhealth.domain.model.WorkoutRecord +import kotlin.random.Random + +/** + * 演示数据生成器。所有方法都是确定性的(同参数、同时刻返回同结果), + * 保证页面展示与截图稳定。M2 接入真机后本类整体弃用。 + * + * @param seed 随机种子,决定历史数据的取值序列。 + * @param now 时间源,默认取系统时钟;测试注入固定值后可得到完全可复现的时间戳 + * (ledger Ruling R17)。 + */ +class MockDataProvider( + private val seed: Long = DEFAULT_SEED, + private val now: () -> Long = System::currentTimeMillis, +) { + + /** 今日概览:固定演示值。血压为 null 以演示“未开启”占位态。 */ + fun todaySummary(): HealthSummary = HealthSummary( + steps = 8426, + stepGoal = 10000, + calories = 327, + distanceKm = 6.21, + heartRate = 72, + sleepMinutes = 452, + bloodOxygen = 98, + stress = 34, + temperature = 36.5, + bloodPressure = null, + ) + + /** + * 指定指标的历史数据:WEEK 返回 7 个点,MONTH 返回 30 个点。 + * 时间戳按“每点间隔一天”倒推,值在指标合理区间内波动。 + */ + fun metricHistory(metric: MetricType, range: DateRange): List { + val count = if (range == DateRange.WEEK) 7 else 30 + val random = Random(seed + metric.ordinal * 100L + range.ordinal) + val (min, max) = valueRange(metric) + val current = now() + val dayMillis = 24L * 60 * 60 * 1000 + return (0 until count).map { index -> + val progress = index.toFloat() / (count - 1).coerceAtLeast(1) + val value = min + (max - min) * progress + random.nextDouble(-1.0, 1.0) * (max - min) * 0.15 + MetricPoint( + timestamp = current - (count - 1 - index) * dayMillis, + value = value.coerceIn(min, max), + ) + } + } + + /** 运动记录:固定 5 条演示数据,倒序(最近的在最前)。 */ + fun workouts(): List { + val random = Random(seed + 7) + val current = now() + val dayMillis = 24L * 60 * 60 * 1000 + val types = listOf("户外跑步", "健走", "骑行", "自由训练", "户外跑步") + return types.mapIndexed { index, type -> + WorkoutRecord( + id = "workout-$index", + type = type, + startTime = current - (index + 1) * dayMillis, + durationMinutes = 25 + random.nextInt(0, 45), + distanceKm = 2.0 + random.nextDouble(0.0, 5.0), + calories = 120 + random.nextInt(0, 260), + ) + } + } + + /** 戒指设备状态:固定演示值。 */ + fun deviceState(): RingDeviceState = RingDeviceState( + connected = true, + name = "RingHealth 智能戒指", + batteryPercent = 78, + firmwareVersion = "1.0.5", + charging = false, + ) + + /** 五个功能开关,对应协议中的自动监测能力。 */ + fun defaultFunctionSettings(): List = listOf( + DeviceFunctionSetting("heart_rate", "自动心率监测", "每 10 分钟测量一次", true), + DeviceFunctionSetting("blood_oxygen", "自动血氧监测", "夜间持续监测", true), + DeviceFunctionSetting("sleep", "睡眠监测", "自动识别睡眠时段", true), + DeviceFunctionSetting("stress", "压力监测", "全天候压力评估", false), + DeviceFunctionSetting("blood_pressure", "血压监测", "需在 App 内手动校准", false), + ) + + /** 各指标的取值区间,用于生成合理的历史数据。 */ + private fun valueRange(metric: MetricType): Pair = when (metric) { + MetricType.STEP -> 3000.0 to 12000.0 + MetricType.HEART_RATE -> 58.0 to 96.0 + MetricType.SLEEP -> 330.0 to 510.0 + MetricType.BLOOD_OXYGEN -> 95.0 to 99.0 + MetricType.STRESS -> 20.0 to 70.0 + MetricType.TEMPERATURE -> 36.1 to 36.9 + MetricType.BLOOD_PRESSURE -> 110.0 to 130.0 + } + + companion object { + /** 默认随机种子,保证演示数据稳定。 */ + const val DEFAULT_SEED: Long = 20260910L + } +} diff --git a/app/src/main/java/com/placeholder/ringhealth/data/mock/MockHealthDataRepository.kt b/app/src/main/java/com/placeholder/ringhealth/data/mock/MockHealthDataRepository.kt new file mode 100644 index 0000000..40191ff --- /dev/null +++ b/app/src/main/java/com/placeholder/ringhealth/data/mock/MockHealthDataRepository.kt @@ -0,0 +1,38 @@ +package com.placeholder.ringhealth.data.mock + +import com.placeholder.ringhealth.domain.model.DateRange +import com.placeholder.ringhealth.domain.model.HealthSummary +import com.placeholder.ringhealth.domain.model.MetricPoint +import com.placeholder.ringhealth.domain.model.MetricType +import com.placeholder.ringhealth.domain.model.WorkoutRecord +import com.placeholder.ringhealth.domain.repository.HealthDataRepository +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +/** + * Mock 健康数据仓库。模拟 200ms 网络/设备延迟,便于阶段一验证加载态。 + */ +class MockHealthDataRepository( + private val provider: MockDataProvider = MockDataProvider(), +) : HealthDataRepository { + + override fun observeTodaySummary(): Flow = flow { + delay(MOCK_DELAY_MILLIS) + emit(provider.todaySummary()) + } + + override fun observeMetricHistory(metric: MetricType, range: DateRange): Flow> = flow { + delay(MOCK_DELAY_MILLIS) + emit(provider.metricHistory(metric, range)) + } + + override fun observeWorkouts(): Flow> = flow { + delay(MOCK_DELAY_MILLIS) + emit(provider.workouts()) + } + + private companion object { + const val MOCK_DELAY_MILLIS = 200L + } +} diff --git a/app/src/main/java/com/placeholder/ringhealth/data/mock/MockRingDeviceRepository.kt b/app/src/main/java/com/placeholder/ringhealth/data/mock/MockRingDeviceRepository.kt new file mode 100644 index 0000000..aa917c1 --- /dev/null +++ b/app/src/main/java/com/placeholder/ringhealth/data/mock/MockRingDeviceRepository.kt @@ -0,0 +1,39 @@ +package com.placeholder.ringhealth.data.mock + +import com.placeholder.ringhealth.domain.model.DeviceFunctionSetting +import com.placeholder.ringhealth.domain.model.RingDeviceState +import com.placeholder.ringhealth.domain.repository.RingDeviceRepository +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +/** + * Mock 设备仓库。开关状态保存在内存中,进程重启后重置(阶段一不做持久化)。 + */ +class MockRingDeviceRepository( + private val provider: MockDataProvider = MockDataProvider(), +) : RingDeviceRepository { + + private val _deviceState = MutableStateFlow(provider.deviceState()) + override val deviceState: StateFlow = _deviceState.asStateFlow() + + private val _functionSettings = MutableStateFlow(provider.defaultFunctionSettings()) + override val functionSettings: StateFlow> = _functionSettings.asStateFlow() + + override suspend fun refreshDeviceState() { + delay(REFRESH_DELAY_MILLIS) + _deviceState.value = provider.deviceState() + } + + override fun setFunctionEnabled(id: String, enabled: Boolean) { + _functionSettings.update { settings -> + settings.map { if (it.id == id) it.copy(enabled = enabled) else it } + } + } + + private companion object { + const val REFRESH_DELAY_MILLIS = 300L + } +} diff --git a/app/src/test/java/com/placeholder/ringhealth/data/mock/MockDataProviderTest.kt b/app/src/test/java/com/placeholder/ringhealth/data/mock/MockDataProviderTest.kt new file mode 100644 index 0000000..106bdba --- /dev/null +++ b/app/src/test/java/com/placeholder/ringhealth/data/mock/MockDataProviderTest.kt @@ -0,0 +1,64 @@ +package com.placeholder.ringhealth.data.mock + +import com.placeholder.ringhealth.domain.model.DateRange +import com.placeholder.ringhealth.domain.model.MetricType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class MockDataProviderTest { + + /** + * 固定时钟。Mock 的「确定性」承诺依赖时间源固定,否则 `timestamp` 会随毫秒跳动 + * (ledger Ruling R17)。 + */ + private val provider = MockDataProvider(now = { FIXED_NOW }) + + private companion object { + /** 固定的“当前时间”,让历史数据的时间戳可复现。 */ + const val FIXED_NOW = 1_760_000_000_000L + } + + @Test + fun `今日概览返回固定演示数据`() { + val summary = provider.todaySummary() + assertEquals(8426, summary.steps) + assertEquals(10000, summary.stepGoal) + assertEquals(327, summary.calories) + assertEquals(72, summary.heartRate) + assertEquals(452, summary.sleepMinutes) + // 血压未开启,应为 null + assertEquals(null, summary.bloodPressure) + } + + @Test + fun `历史数据长度与范围一致`() { + assertEquals(7, provider.metricHistory(MetricType.STEP, DateRange.WEEK).size) + assertEquals(30, provider.metricHistory(MetricType.STEP, DateRange.MONTH).size) + } + + @Test + fun `历史数据可重复生成且值合理`() { + val first = provider.metricHistory(MetricType.HEART_RATE, DateRange.WEEK) + val second = provider.metricHistory(MetricType.HEART_RATE, DateRange.WEEK) + assertEquals(first, second) + assertTrue(first.all { it.value > 40.0 && it.value < 180.0 }) + // 时间戳须严格递增(“每点间隔一天”倒推,最早的在最前) + assertTrue(first.zipWithNext().all { (a, b) -> b.timestamp > a.timestamp }) + } + + @Test + fun `设备状态与开关数据合法`() { + val device = provider.deviceState() + assertTrue(device.batteryPercent in 0..100) + assertEquals(true, device.connected) + assertEquals(5, provider.defaultFunctionSettings().size) + } + + @Test + fun `运动记录非空且字段合理`() { + val records = provider.workouts() + assertTrue(records.isNotEmpty()) + assertTrue(records.all { it.durationMinutes > 0 && it.calories > 0 }) + } +}