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) -> Unit, ) = runTest { val events = mutableListOf() 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) } }