Files
MicrOBU/app/src/test/java/com/hawhamburg/micr0bu/EventDetectorTest.kt
T
2026-06-03 14:51:31 +02:00

355 lines
16 KiB
Kotlin

package com.hawhamburg.micr0bu
import com.hawhamburg.micr0bu.domain.detection.Confidence
import com.hawhamburg.micr0bu.domain.detection.DetectedEvent
import com.hawhamburg.micr0bu.domain.detection.DetectionConfig
import com.hawhamburg.micr0bu.domain.detection.EventDetector
import com.hawhamburg.micr0bu.domain.detection.EventType
import com.hawhamburg.micr0bu.domain.detection.RunningStats
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import kotlin.math.sqrt
/**
* Unit tests for [EventDetector] and [RunningStats] using synthetic sensor data.
*
* No Android emulator required — all production classes have zero Android imports.
*
* The test [config] uses a smaller window and fewer sustained frames than the
* production defaults so tests run in milliseconds without generating thousands
* of synthetic samples.
*
* Accel-std-dev notes
* -------------------
* A production threshold of 1.2 m/s² requires genuine variability in the window.
* In the "hard brake" tests we alternate between high and low accel values
* (e.g. 3.5 / 0.5), which yields std dev ≈ 1.5 with a 10-sample window.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class EventDetectorTest {
/** Tighter config so fewer frames are needed to trigger each event. */
private val config = DetectionConfig(
windowSize = 10,
brakingSustainedFrames = 5,
turningSustainedFrames = 8,
stoppingFrames = 20,
// Keep production thresholds for all signal values:
brakingSpeedDropThreshold = 0.5,
brakingAccelStdDevThreshold = 1.2,
brakingHighConfidenceRate = 1.5,
turningGyroMeanThreshold = 0.4,
turningBearingChangeThreshold = 10.0,
turningMinSpeedThreshold = 2.0,
stoppingSpeedThreshold = 0.5,
stoppingAccelStdDevThreshold = 0.15,
)
private lateinit var detector: EventDetector
@Before fun setUp() { detector = EventDetector(config) }
// ─── Helpers ──────────────────────────────────────────────────────────────
/** Runs [block] inside a coroutine that collects all emitted events. */
private fun runCollecting(
block: suspend kotlinx.coroutines.test.TestScope.(events: MutableList<DetectedEvent>) -> Unit,
) = runTest {
val events = mutableListOf<DetectedEvent>()
val job = launch(UnconfinedTestDispatcher(testScheduler)) {
detector.events.collect { events.add(it) }
}
block(events)
job.cancel()
}
/**
* Produces [n] frames with alternating accelMagnitude values of [hi] and [lo],
* giving a population std dev of |hi - lo| / 2, which exceeds the production
* threshold of 1.2 m/s² when hi=3.5 and lo=0.5 (std dev = 1.5).
*/
private fun alternatingAccelFrames(
n: Int,
hi: Double = 3.5,
lo: Double = 0.5,
speedMps: Double = 10.0,
bearingChangeDps: Double = 0.0,
timeOffset: Int = 0,
) {
repeat(n) { i ->
detector.processSample(
accelMagnitude = if (i % 2 == 0) hi else lo,
gyroMagnitude = 0.05,
speedMps = speedMps,
bearingChangeDegPerSec = bearingChangeDps,
latitude = 53.5,
longitude = 10.0,
timestamp = (timeOffset + i) * 20L,
)
}
}
// ─── Normal riding — no false triggers ───────────────────────────────────
@Test fun `normal riding produces no events`() = runCollecting { events ->
// Steady 5 m/s, low gyro, very low accel variance (constant value → stdDev = 0)
repeat(50) { i ->
detector.processSample(
accelMagnitude = 0.2,
gyroMagnitude = 0.1,
speedMps = 5.0,
bearingChangeDegPerSec = 2.0,
latitude = 53.5,
longitude = 10.0,
timestamp = i * 20L,
)
}
assertTrue("No events expected during steady riding, got: $events", events.isEmpty())
}
// ─── Hard brake ───────────────────────────────────────────────────────────
@Test fun `hard brake triggers BRAKING event`() = runCollecting { events ->
// Phase 1: fill window at 10 m/s with constant accel (no std dev → no braking)
repeat(config.windowSize) { i ->
detector.processSample(1.0, 0.05, 10.0, 0.0, 53.5, 10.0, i * 20L)
}
// Phase 2: GPS drops to 4 m/s (drop = 6 m/s > 0.5 threshold).
// Alternate hi/lo accel to exceed the std-dev threshold.
alternatingAccelFrames(
n = config.brakingSustainedFrames + 5,
speedMps = 4.0,
timeOffset = config.windowSize,
)
val braking = events.filter { it.type == EventType.BRAKING }
assertTrue("Expected at least one BRAKING event, got: $events", braking.isNotEmpty())
}
@Test fun `hard brake with large speed drop has HIGH confidence`() = runCollecting { events ->
repeat(config.windowSize) { i ->
detector.processSample(1.0, 0.05, 10.0, 0.0, 53.5, 10.0, i * 20L)
}
// Drop of 8 m/s > brakingHighConfidenceRate (1.5)
alternatingAccelFrames(
n = config.brakingSustainedFrames + 5,
hi = 3.5,
lo = 0.5,
speedMps = 2.0, // drop from 10 → 8 m/s
timeOffset = config.windowSize,
)
val braking = events.filter { it.type == EventType.BRAKING }
assertTrue(braking.isNotEmpty())
assertEquals(
"Large speed drop should yield HIGH confidence",
Confidence.HIGH,
braking.first().confidence,
)
}
@Test fun `moderate speed drop has MEDIUM confidence`() = runCollecting { events ->
repeat(config.windowSize) { i ->
detector.processSample(1.0, 0.05, 3.0, 0.0, 53.5, 10.0, i * 20L)
}
// Drop of 0.8 m/s — above speed-drop threshold (0.5) but below high-conf rate (1.5)
alternatingAccelFrames(
n = config.brakingSustainedFrames + 5,
hi = 3.5,
lo = 0.5,
speedMps = 2.2, // drop = 0.8 m/s
timeOffset = config.windowSize,
)
val braking = events.filter { it.type == EventType.BRAKING }
assertTrue(braking.isNotEmpty())
assertEquals(Confidence.MEDIUM, braking.first().confidence)
}
// ─── Left turn ────────────────────────────────────────────────────────────
@Test fun `sustained high gyro above 2ms triggers TURNING event`() = runCollecting { events ->
val total = config.windowSize + config.turningSustainedFrames + 4
repeat(total) { i ->
detector.processSample(
accelMagnitude = 0.3,
gyroMagnitude = 0.8, // mean → well above 0.4 threshold
speedMps = 4.0, // above 2 m/s → bearing also checked
bearingChangeDegPerSec = 15.0, // above 10 °/s → both signals agree
latitude = 53.5,
longitude = 10.0,
timestamp = i * 20L,
)
}
assertTrue("Expected TURNING event, got: $events", events.any { it.type == EventType.TURNING })
}
@Test fun `turning with both signals agreeing gets HIGH confidence`() = runCollecting { events ->
val total = config.windowSize + config.turningSustainedFrames + 4
repeat(total) { i ->
detector.processSample(0.3, 0.8, 4.0, 15.0, 53.5, 10.0, i * 20L)
}
val turning = events.filter { it.type == EventType.TURNING }
assertTrue(turning.isNotEmpty())
assertEquals(Confidence.HIGH, turning.first().confidence)
}
@Test fun `turning at low speed with gyro only gets LOW confidence`() = runCollecting { events ->
val total = config.windowSize + config.turningSustainedFrames + 4
repeat(total) { i ->
detector.processSample(
accelMagnitude = 0.2,
gyroMagnitude = 0.6, // above gyro threshold
speedMps = 1.0, // below 2 m/s → bearing not enforced
bearingChangeDegPerSec = 3.0, // below bearing threshold
latitude = 53.5,
longitude = 10.0,
timestamp = i * 20L,
)
}
val turning = events.filter { it.type == EventType.TURNING }
assertTrue("Expected TURNING event at low speed, got: $events", turning.isNotEmpty())
assertEquals("Low-speed turn should be LOW confidence", Confidence.LOW, turning.first().confidence)
}
// ─── Full stop ────────────────────────────────────────────────────────────
@Test fun `full stop for required frames triggers STOPPING event`() = runCollecting { events ->
val frames = config.stoppingFrames + 5
repeat(frames) { i ->
detector.processSample(
accelMagnitude = 0.02, // constant → stdDev = 0 < 0.15
gyroMagnitude = 0.01,
speedMps = 0.1, // < 0.5 threshold
bearingChangeDegPerSec = 0.0,
latitude = 53.5,
longitude = 10.0,
timestamp = i * 20L,
)
}
val stopping = events.filter { it.type == EventType.STOPPING }
assertTrue("Expected STOPPING event, got: $events", stopping.isNotEmpty())
assertEquals("STOPPING should always be HIGH confidence", Confidence.HIGH, stopping.first().confidence)
}
@Test fun `STOPPING emitted exactly once per stop episode`() = runCollecting { events ->
val frames = config.stoppingFrames + 30
repeat(frames) { i ->
detector.processSample(0.02, 0.01, 0.1, 0.0, 53.5, 10.0, i * 20L)
}
val stopping = events.filter { it.type == EventType.STOPPING }
assertEquals("STOPPING must emit exactly once per episode", 1, stopping.size)
}
// ─── Bag movement while stationary ───────────────────────────────────────
@Test fun `bag movement while stationary does not trigger BRAKING or TURNING`() = runCollecting { events ->
// Speed stays at zero; occasional accel/gyro spikes from bag jostle
repeat(50) { i ->
val accel = if (i % 5 == 0) 1.8 else 0.3 // jitter but mean is below std-dev threshold
val gyro = if (i % 7 == 0) 0.35 else 0.05 // occasional spike but mean stays < 0.4
detector.processSample(
accelMagnitude = accel,
gyroMagnitude = gyro,
speedMps = 0.0,
bearingChangeDegPerSec = 1.0,
latitude = 53.5,
longitude = 10.0,
timestamp = i * 20L,
)
}
// speed = 0 → no speed drop possible → no BRAKING
// gyro mean stays below 0.4 (only 1/7 frames spike to 0.35) → no TURNING
val unwanted = events.filter { it.type == EventType.BRAKING || it.type == EventType.TURNING }
assertTrue("Bag movement must not trigger BRAKING or TURNING, got: $events", unwanted.isEmpty())
}
// ─── Starting from stationary ─────────────────────────────────────────────
@Test fun `starting from stationary does not produce false BRAKING event`() = runCollecting { events ->
// Begin at rest then accelerate — speed only INCREASES
repeat(10) { i ->
detector.processSample(0.1, 0.05, 0.0, 0.0, 53.5, 10.0, i * 20L)
}
repeat(20) { i ->
val speed = i * 0.3 // 0 → 5.7 m/s — monotonically increasing
detector.processSample(0.8, 0.1, speed, 1.0, 53.5, 10.0, (10 + i) * 20L)
}
val braking = events.filter { it.type == EventType.BRAKING }
assertTrue("Starting from stationary must not trigger BRAKING, got: $events", braking.isEmpty())
}
@Test fun `stopping then re-accelerating then stopping triggers two STOPPING events`() = runCollecting { events ->
val stopFrames = config.stoppingFrames + 5
var t = 0
// First stop episode
repeat(stopFrames) {
detector.processSample(0.02, 0.01, 0.1, 0.0, 53.5, 10.0, t++ * 20L)
}
// Brief acceleration — resets the stopping counter
repeat(5) {
detector.processSample(0.5, 0.1, 5.0, 2.0, 53.5, 10.0, t++ * 20L)
}
// Second stop episode
repeat(stopFrames) {
detector.processSample(0.02, 0.01, 0.1, 0.0, 53.5, 10.0, t++ * 20L)
}
val stopping = events.filter { it.type == EventType.STOPPING }
assertEquals("Expected two STOPPING events (one per episode)", 2, stopping.size)
}
// ─── RunningStats unit tests ──────────────────────────────────────────────
@Test fun `RunningStats mean and stdDev are correct for known sequence`() {
val stats = RunningStats(windowSize = 4)
// [2, 4, 4, 4] → mean = 3.5, variance = 0.75
stats.add(2.0); stats.add(4.0); stats.add(4.0); stats.add(4.0)
assertEquals(3.5, stats.mean(), 1e-9)
assertEquals(0.75, stats.variance(), 1e-9)
assertEquals(sqrt(0.75), stats.stdDev(), 1e-9)
}
@Test fun `RunningStats evicts oldest value when window is full`() {
val stats = RunningStats(windowSize = 3)
stats.add(1.0); stats.add(2.0); stats.add(3.0)
assertEquals(2.0, stats.mean(), 1e-9) // [1,2,3] → 2.0
stats.add(10.0)
assertEquals(5.0, stats.mean(), 1e-9) // [2,3,10] → 5.0
}
@Test fun `RunningStats size grows correctly and caps at windowSize`() {
val stats = RunningStats(windowSize = 5)
assertEquals(0, stats.size())
stats.add(1.0); assertEquals(1, stats.size())
stats.add(1.0); assertEquals(2, stats.size())
repeat(10) { stats.add(1.0) }
assertEquals(5, stats.size())
}
@Test fun `RunningStats reset clears all state`() {
val stats = RunningStats(windowSize = 5)
repeat(5) { stats.add(it.toDouble()) }
stats.reset()
assertEquals(0, stats.size())
assertEquals(0.0, stats.mean(), 1e-9)
assertEquals(0.0, stats.stdDev(), 1e-9)
}
@Test fun `RunningStats variance is zero for constant sequence`() {
val stats = RunningStats(windowSize = 10)
repeat(10) { stats.add(3.14) }
assertEquals(0.0, stats.variance(), 1e-9)
}
@Test fun `RunningStats returns zero mean and stdDev for empty window`() {
val stats = RunningStats(windowSize = 10)
assertEquals(0.0, stats.mean(), 1e-9)
assertEquals(0.0, stats.stdDev(), 1e-9)
}
}