Compare commits

..
2 Commits
6 changed files with 294 additions and 2 deletions
@@ -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()
}
@@ -3,6 +3,16 @@ package com.placeholder.ringhealth
import android.app.Application 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()
}
}
@@ -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<MetricPoint> {
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<WorkoutRecord> {
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<DeviceFunctionSetting> = 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<Double, Double> = 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
}
}
@@ -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<HealthSummary> = flow {
delay(MOCK_DELAY_MILLIS)
emit(provider.todaySummary())
}
override fun observeMetricHistory(metric: MetricType, range: DateRange): Flow<List<MetricPoint>> = flow {
delay(MOCK_DELAY_MILLIS)
emit(provider.metricHistory(metric, range))
}
override fun observeWorkouts(): Flow<List<WorkoutRecord>> = flow {
delay(MOCK_DELAY_MILLIS)
emit(provider.workouts())
}
private companion object {
const val MOCK_DELAY_MILLIS = 200L
}
}
@@ -0,0 +1,45 @@
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 设备仓库。开关状态保存在内存中,进程重启后重置(阶段一不做持久化)。
*
* 注意 [refreshDeviceState] 的语义:它以 **suspend 函数正常返回**表示刷新完成,
* 而**不是**靠发射新状态。`MutableStateFlow` 会抑制相等值,M1 的设备状态是常量,
* 因此刷新不会产生任何可观察的发射。调用方要展示刷新反馈,应当
* `launch { repo.refreshDeviceState(); isRefreshing = false }`,
* **不要**写成「等待下一个不同的 deviceState 发射」(那样在 M1 会永远等不到)。
*/
class MockRingDeviceRepository(
private val provider: MockDataProvider = MockDataProvider(),
) : RingDeviceRepository {
private val _deviceState = MutableStateFlow(provider.deviceState())
override val deviceState: StateFlow<RingDeviceState> = _deviceState.asStateFlow()
private val _functionSettings = MutableStateFlow(provider.defaultFunctionSettings())
override val functionSettings: StateFlow<List<DeviceFunctionSetting>> = _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
}
}
@@ -0,0 +1,69 @@
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 })
// 断言绝对时间戳,让注入的时钟真正成为被测对象:若退回 System.currentTimeMillis(),
// 这两条会立刻失败(ledger R17 / R20)。只比较 first == second 是不够的 ——
// 两次相邻调用几乎总落在同一毫秒,那时去掉时钟注入测试照样通过。
assertEquals(FIXED_NOW, first.last().timestamp)
assertEquals(FIXED_NOW - 6L * 24 * 60 * 60 * 1000, first.first().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 })
}
}