feat: 新增 Mock 数据层与依赖容器(含单元测试)
This commit is contained in:
@@ -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,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<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,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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user