From d3854ac40208dfc4fe1ba136f5ef7bedca741556 Mon Sep 17 00:00:00 2001 From: Jay Date: Mon, 13 Jul 2026 14:51:28 -0400 Subject: [PATCH] Slice 2: guided Surface calibration flow (Codex) - SurfaceCalibrationScreen: two-sample guided flow from the Level header. Captures read SensorSource.gravity directly - stored calibration is never applied to either sample. Exact 180-degree same-plane guidance, clear-saved-calibration action, Surface persistence only. - SettlingDetector (core/sensors, shared): 1.0 deg/s enter, 0.3 deg/s exit held 500 ms; mid-band movement resets the quiet dwell without leaving Settling (audit finding 1). - StableSurfaceCapture (core/sensors): continuous 1.5 s settled window with the 5-degree calibration-surface guard; restarts on motion. - SurfaceCalibrationValidation (core/sensors): 3-degree bound on the combined two-axis bias magnitude, unit-tested both sides of the boundary (audit finding 2). - Lock feedback suppression is structural: the calibration route removes LevelScreen from composition, cancelling its sensor collection. 50 tests passing. Audited-by: Claude (2 findings raised and resolved) Co-Authored-By: Claude Fable 5 --- .../main/java/com/onthelevel/MainActivity.kt | 10 +- .../core/sensors/SettlingDetector.kt | 70 ++++++ .../core/sensors/StableSurfaceCapture.kt | 49 +++++ .../sensors/SurfaceCalibrationValidation.kt | 14 ++ .../core/settings/SettingsRepository.kt | 2 + .../onthelevel/feature/level/LevelScreen.kt | 4 +- .../feature/level/SurfaceCalibrationScreen.kt | 203 ++++++++++++++++++ .../core/sensors/SettlingDetectorTest.kt | 43 ++++ .../core/sensors/StableSurfaceCaptureTest.kt | 42 ++++ .../SurfaceCalibrationValidationTest.kt | 20 ++ 10 files changed, 454 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/com/onthelevel/core/sensors/SettlingDetector.kt create mode 100644 app/src/main/java/com/onthelevel/core/sensors/StableSurfaceCapture.kt create mode 100644 app/src/main/java/com/onthelevel/core/sensors/SurfaceCalibrationValidation.kt create mode 100644 app/src/main/java/com/onthelevel/feature/level/SurfaceCalibrationScreen.kt create mode 100644 app/src/test/java/com/onthelevel/core/sensors/SettlingDetectorTest.kt create mode 100644 app/src/test/java/com/onthelevel/core/sensors/StableSurfaceCaptureTest.kt create mode 100644 app/src/test/java/com/onthelevel/core/sensors/SurfaceCalibrationValidationTest.kt diff --git a/app/src/main/java/com/onthelevel/MainActivity.kt b/app/src/main/java/com/onthelevel/MainActivity.kt index f3741e0..59e9bc4 100644 --- a/app/src/main/java/com/onthelevel/MainActivity.kt +++ b/app/src/main/java/com/onthelevel/MainActivity.kt @@ -26,6 +26,7 @@ import androidx.navigation.compose.rememberNavController import com.onthelevel.core.design.OnTheLevelTheme import com.onthelevel.feature.angle.AngleScreen import com.onthelevel.feature.level.LevelScreen +import com.onthelevel.feature.level.SurfaceCalibrationScreen import com.onthelevel.feature.ruler.RulerScreen import com.onthelevel.feature.tools.ToolsScreen @@ -87,12 +88,19 @@ private fun AppRoot(container: AppContainer) { startDestination = "level", modifier = Modifier.padding(padding), ) { - composable("level") { LevelScreen(container) } + composable("level") { + LevelScreen(container, onOpenSurfaceCalibration = { + navController.navigate("surface-calibration") + }) + } composable("angle") { AngleScreen(container) } composable("tools") { ToolsScreen(onOpenRuler = { navController.navigate("ruler") }) } composable("ruler") { RulerScreen(container) } + composable("surface-calibration") { + SurfaceCalibrationScreen(container, onBack = { navController.popBackStack() }) + } } } } diff --git a/app/src/main/java/com/onthelevel/core/sensors/SettlingDetector.kt b/app/src/main/java/com/onthelevel/core/sensors/SettlingDetector.kt new file mode 100644 index 0000000..bfa6f49 --- /dev/null +++ b/app/src/main/java/com/onthelevel/core/sensors/SettlingDetector.kt @@ -0,0 +1,70 @@ +package com.onthelevel.core.sensors + +import kotlin.math.hypot + +/** Shared two-axis motion gate for calibration, guidance, and Audio Assist. */ +class SettlingDetector( + private val enterRateDegreesPerSecond: Double = ENTER_RATE_DEGREES_PER_SECOND, + private val exitRateDegreesPerSecond: Double = EXIT_RATE_DEGREES_PER_SECOND, + private val settledDwellMillis: Long = SETTLED_DWELL_MILLIS, +) { + data class Result(val isSettling: Boolean, val movementRateDegreesPerSecond: Double) + + private var lastPitchDegrees: Double? = null + private var lastRollDegrees: Double? = null + private var lastTimestampNanos: Long? = null + private var settledSinceMillis: Long? = null + private var isSettling = true + + fun update(pitchDegrees: Double, rollDegrees: Double, timestampNanos: Long): Result { + val previousPitch = lastPitchDegrees + val previousRoll = lastRollDegrees + val previousTimestamp = lastTimestampNanos + lastPitchDegrees = pitchDegrees + lastRollDegrees = rollDegrees + lastTimestampNanos = timestampNanos + + if (previousPitch == null || previousRoll == null || previousTimestamp == null) { + return Result(isSettling = true, movementRateDegreesPerSecond = 0.0) + } + val dtSeconds = (timestampNanos - previousTimestamp) / 1e9 + if (dtSeconds <= 0.0) { + reset() + return Result(isSettling = true, movementRateDegreesPerSecond = 0.0) + } + + val rate = hypot(pitchDegrees - previousPitch, rollDegrees - previousRoll) / dtSeconds + val nowMillis = timestampNanos / 1_000_000 + when { + rate > enterRateDegreesPerSecond -> { + isSettling = true + settledSinceMillis = null + } + isSettling && rate > exitRateDegreesPerSecond -> { + // A mid-band hand nudge is not enough to leave Settling, but it is + // enough to prove the signal has not been continuously quiet for + // the required dwell window. + settledSinceMillis = null + } + isSettling && rate <= exitRateDegreesPerSecond -> { + val since = settledSinceMillis ?: nowMillis.also { settledSinceMillis = it } + if (nowMillis - since >= settledDwellMillis) isSettling = false + } + } + return Result(isSettling = isSettling, movementRateDegreesPerSecond = rate) + } + + fun reset() { + lastPitchDegrees = null + lastRollDegrees = null + lastTimestampNanos = null + settledSinceMillis = null + isSettling = true + } + + companion object { + const val ENTER_RATE_DEGREES_PER_SECOND = 1.0 + const val EXIT_RATE_DEGREES_PER_SECOND = 0.3 + const val SETTLED_DWELL_MILLIS = 500L + } +} diff --git a/app/src/main/java/com/onthelevel/core/sensors/StableSurfaceCapture.kt b/app/src/main/java/com/onthelevel/core/sensors/StableSurfaceCapture.kt new file mode 100644 index 0000000..5e05895 --- /dev/null +++ b/app/src/main/java/com/onthelevel/core/sensors/StableSurfaceCapture.kt @@ -0,0 +1,49 @@ +package com.onthelevel.core.sensors + +/** Uncalibrated, smoothed Surface sample used by guided calibration. */ +data class SurfaceCalibrationSample( + val pitchDegrees: Double, + val rollDegrees: Double, + val tiltDegrees: Double, + val timestampNanos: Long, + val isSettling: Boolean, +) + +/** Mean of one settled 1.5-second calibration capture. */ +data class CapturedSurfaceSample(val pitchDegrees: Double, val rollDegrees: Double) + +/** Collects a continuous settled capture on a roughly-level surface. */ +class StableSurfaceCapture( + private val durationMillis: Long = CAPTURE_DURATION_MILLIS, + private val maximumTiltDegrees: Double = MAXIMUM_CALIBRATION_TILT_DEGREES, +) { + private var startedAtNanos: Long? = null + private var pitchTotal = 0.0 + private var rollTotal = 0.0 + private var count = 0 + + fun add(sample: SurfaceCalibrationSample): CapturedSurfaceSample? { + if (sample.isSettling || sample.tiltDegrees > maximumTiltDegrees) { + reset() + return null + } + val startedAt = startedAtNanos ?: sample.timestampNanos.also { startedAtNanos = it } + pitchTotal += sample.pitchDegrees + rollTotal += sample.rollDegrees + count += 1 + if ((sample.timestampNanos - startedAt) / 1_000_000 < durationMillis || count < 2) return null + return CapturedSurfaceSample(pitchTotal / count, rollTotal / count) + } + + fun reset() { + startedAtNanos = null + pitchTotal = 0.0 + rollTotal = 0.0 + count = 0 + } + + companion object { + const val CAPTURE_DURATION_MILLIS = 1_500L + const val MAXIMUM_CALIBRATION_TILT_DEGREES = 5.0 + } +} diff --git a/app/src/main/java/com/onthelevel/core/sensors/SurfaceCalibrationValidation.kt b/app/src/main/java/com/onthelevel/core/sensors/SurfaceCalibrationValidation.kt new file mode 100644 index 0000000..1cb0b57 --- /dev/null +++ b/app/src/main/java/com/onthelevel/core/sensors/SurfaceCalibrationValidation.kt @@ -0,0 +1,14 @@ +package com.onthelevel.core.sensors + +import kotlin.math.hypot + +/** + * Guards calibration persistence against an inconsistent two-sample turn. The bound + * applies to the combined two-axis correction, not each axis independently. + */ +object SurfaceCalibrationValidation { + const val MAXIMUM_BIAS_MAGNITUDE_DEGREES = 3.0 + + fun isAcceptable(calibration: SurfaceCalibration): Boolean = + hypot(calibration.pitchBiasDegrees, calibration.rollBiasDegrees) <= MAXIMUM_BIAS_MAGNITUDE_DEGREES +} diff --git a/app/src/main/java/com/onthelevel/core/settings/SettingsRepository.kt b/app/src/main/java/com/onthelevel/core/settings/SettingsRepository.kt index 08d03f1..2bae321 100644 --- a/app/src/main/java/com/onthelevel/core/settings/SettingsRepository.kt +++ b/app/src/main/java/com/onthelevel/core/settings/SettingsRepository.kt @@ -49,6 +49,8 @@ class SettingsRepository(context: Context) { } } + suspend fun clearSurfaceCalibration() = setSurfaceCalibration(SurfaceCalibration.NONE) + suspend fun setEdgeCalibration(calibration: EdgeCalibration) { store.edit { it[Keys.EDGE_LEVEL_BIAS] = calibration.levelBiasDegrees diff --git a/app/src/main/java/com/onthelevel/feature/level/LevelScreen.kt b/app/src/main/java/com/onthelevel/feature/level/LevelScreen.kt index ec6c45b..0f0912e 100644 --- a/app/src/main/java/com/onthelevel/feature/level/LevelScreen.kt +++ b/app/src/main/java/com/onthelevel/feature/level/LevelScreen.kt @@ -54,7 +54,7 @@ import java.util.Locale * while the portrait-locked device stands on its long edge. */ @Composable -fun LevelScreen(container: AppContainer) { +fun LevelScreen(container: AppContainer, onOpenSurfaceCalibration: () -> Unit) { KeepScreenOn() val sensorSource = container.sensorSource @@ -107,7 +107,7 @@ fun LevelScreen(container: AppContainer) { color = if (isCalibrated) LevelColors.LimeLock else LevelColors.TextFaint, ) } - IconButton(onClick = { /* TODO(feature): calibration & settings entry */ }) { + IconButton(onClick = onOpenSurfaceCalibration) { Icon( Icons.Outlined.Settings, contentDescription = "Calibration and settings", diff --git a/app/src/main/java/com/onthelevel/feature/level/SurfaceCalibrationScreen.kt b/app/src/main/java/com/onthelevel/feature/level/SurfaceCalibrationScreen.kt new file mode 100644 index 0000000..0fe5834 --- /dev/null +++ b/app/src/main/java/com/onthelevel/feature/level/SurfaceCalibrationScreen.kt @@ -0,0 +1,203 @@ +package com.onthelevel.feature.level + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.onthelevel.AppContainer +import com.onthelevel.core.design.KeepScreenOn +import com.onthelevel.core.design.LevelColors +import com.onthelevel.core.sensors.CapturedSurfaceSample +import com.onthelevel.core.sensors.Ema +import com.onthelevel.core.sensors.OrientationMath +import com.onthelevel.core.sensors.SettlingDetector +import com.onthelevel.core.sensors.StableSurfaceCapture +import com.onthelevel.core.sensors.SurfaceCalibration +import com.onthelevel.core.sensors.SurfaceCalibrationSample +import com.onthelevel.core.sensors.SurfaceCalibrationValidation +import com.onthelevel.core.sensors.TwoSampleCalibration +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +private enum class CalibrationPhase { FIRST_READY, FIRST_CAPTURING, SECOND_READY, SECOND_CAPTURING, COMPLETE, ERROR } + +/** + * Guided two-sample Surface calibration. Capture reads SensorSource.gravity directly + * and never applies stored SurfaceCalibration to either sample. + */ +@Composable +fun SurfaceCalibrationScreen(container: AppContainer, onBack: () -> Unit) { + KeepScreenOn() + + val sensorSource = container.sensorSource + val scope = rememberCoroutineScope() + var phase by remember { mutableStateOf(CalibrationPhase.FIRST_READY) } + var firstCapture by remember { mutableStateOf(null) } + var capture by remember { mutableStateOf(null) } + var error by remember { mutableStateOf(null) } + val existingCalibration by container.settings.surfaceCalibration + .collectAsStateWithLifecycle(initialValue = SurfaceCalibration.NONE) + + val rawReadingFlow = remember(sensorSource) { + val pitchEma = Ema(0.15) + val rollEma = Ema(0.15) + val tiltEma = Ema(0.15) + val settling = SettlingDetector() + var lastNanos: Long? = null + sensorSource.gravity.map { raw -> + val dtSeconds = lastNanos?.let { (raw.timestampNanos - it) / 1e9 } ?: 0.0 + lastNanos = raw.timestampNanos + val pitch = pitchEma.update(OrientationMath.surfacePitchDegrees(raw), dtSeconds) + val roll = rollEma.update(OrientationMath.surfaceRollDegrees(raw), dtSeconds) + val tilt = tiltEma.update(OrientationMath.surfaceTiltMagnitudeDegrees(raw), dtSeconds) + val settlingResult = settling.update(pitch, roll, raw.timestampNanos) + SurfaceCalibrationSample(pitch, roll, tilt, raw.timestampNanos, settlingResult.isSettling) + } + } + val reading by rawReadingFlow.collectAsStateWithLifecycle(initialValue = null) + + LaunchedEffect(reading, phase, capture, firstCapture) { + val activeCapture = capture ?: return@LaunchedEffect + val current = reading ?: return@LaunchedEffect + val captured = activeCapture.add(current) ?: return@LaunchedEffect + when (phase) { + CalibrationPhase.FIRST_CAPTURING -> { + firstCapture = captured + capture = null + phase = CalibrationPhase.SECOND_READY + } + CalibrationPhase.SECOND_CAPTURING -> { + val first = firstCapture ?: return@LaunchedEffect + val calibration = TwoSampleCalibration.deriveSurface( + first.pitchDegrees, + first.rollDegrees, + captured.pitchDegrees, + captured.rollDegrees, + ) + capture = null + if (!SurfaceCalibrationValidation.isAcceptable(calibration)) { + error = "The placements did not agree. Keep the phone on the same spot and rotate it exactly halfway around." + phase = CalibrationPhase.ERROR + } else { + container.settings.setSurfaceCalibration(calibration) + phase = CalibrationPhase.COMPLETE + } + } + else -> Unit + } + } + + val canStartCapture = reading?.let { + !it.isSettling && it.tiltDegrees <= StableSurfaceCapture.MAXIMUM_CALIBRATION_TILT_DEGREES + } ?: false + val status = when { + reading == null -> "Waiting for a sensor reading" + reading!!.isSettling -> "Settling—keep the phone still" + reading!!.tiltDegrees > StableSurfaceCapture.MAXIMUM_CALIBRATION_TILT_DEGREES -> + "Find a flatter surface (under 5°)" + else -> "Ready to capture" + } + + Column( + modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(18.dp), + ) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text("SURFACE CALIBRATION", style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim) + OutlinedButton(onClick = onBack) { Text("Done") } + } + Text(calibrationInstruction(phase), style = MaterialTheme.typography.headlineMedium, color = LevelColors.TextPrimary) + Text( + "This corrects your phone and case setup. It does not make this surface level.", + style = MaterialTheme.typography.bodyMedium, + color = LevelColors.TextDim, + ) + + Box(modifier = Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + reading?.let { formatDegrees(it.tiltDegrees) } ?: "—", + style = MaterialTheme.typography.displayLarge, + color = LevelColors.TextPrimary, + ) + Text(status, style = MaterialTheme.typography.bodyLarge, color = LevelColors.Amber) + } + } + + when (phase) { + CalibrationPhase.FIRST_READY, CalibrationPhase.SECOND_READY -> Button( + onClick = { + capture = StableSurfaceCapture() + phase = if (phase == CalibrationPhase.FIRST_READY) CalibrationPhase.FIRST_CAPTURING else CalibrationPhase.SECOND_CAPTURING + }, + enabled = canStartCapture, + modifier = Modifier.fillMaxWidth(), + ) { + Text(if (phase == CalibrationPhase.FIRST_READY) "Capture first position" else "Capture rotated position") + } + CalibrationPhase.FIRST_CAPTURING, CalibrationPhase.SECOND_CAPTURING -> Text( + "Capturing 1.5 seconds of still readings…", + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyLarge, + color = LevelColors.TextDim, + ) + CalibrationPhase.COMPLETE -> { + Text( + "Surface calibration saved for this phone/case setup.", + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyLarge, + color = LevelColors.LimeLockText, + ) + Button(onClick = onBack, modifier = Modifier.fillMaxWidth()) { Text("Back to level") } + } + CalibrationPhase.ERROR -> { + Text(error ?: "Calibration could not be completed.", style = MaterialTheme.typography.bodyLarge, color = LevelColors.Amber) + Button( + onClick = { + firstCapture = null + error = null + phase = CalibrationPhase.FIRST_READY + }, + modifier = Modifier.fillMaxWidth(), + ) { Text("Try again") } + } + } + + if (existingCalibration != SurfaceCalibration.NONE && phase != CalibrationPhase.FIRST_CAPTURING && phase != CalibrationPhase.SECOND_CAPTURING) { + OutlinedButton( + onClick = { scope.launch { container.settings.clearSurfaceCalibration() } }, + modifier = Modifier.fillMaxWidth(), + ) { Text("Clear saved calibration") } + } + } +} + +private fun calibrationInstruction(phase: CalibrationPhase): String = when (phase) { + CalibrationPhase.FIRST_READY, CalibrationPhase.FIRST_CAPTURING -> + "Lay the phone screen-up on a firm, roughly level surface. Keep it still." + CalibrationPhase.SECOND_READY, CalibrationPhase.SECOND_CAPTURING -> + "Rotate the phone exactly 180° in the same plane. Do not flip it over or move it." + CalibrationPhase.COMPLETE -> "Calibration complete" + CalibrationPhase.ERROR -> "Try Surface calibration again" +} diff --git a/app/src/test/java/com/onthelevel/core/sensors/SettlingDetectorTest.kt b/app/src/test/java/com/onthelevel/core/sensors/SettlingDetectorTest.kt new file mode 100644 index 0000000..1717a07 --- /dev/null +++ b/app/src/test/java/com/onthelevel/core/sensors/SettlingDetectorTest.kt @@ -0,0 +1,43 @@ +package com.onthelevel.core.sensors + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SettlingDetectorTest { + + @Test + fun `settles only after low movement has held for its dwell time`() { + val detector = SettlingDetector() + + assertTrue(detector.update(0.0, 0.0, 0).isSettling) + assertTrue(detector.update(0.2, 0.0, 100_000_000).isSettling) // 2°/s: moving + assertTrue(detector.update(0.2, 0.0, 200_000_000).isSettling) + assertTrue(detector.update(0.2, 0.0, 600_000_000).isSettling) + assertFalse(detector.update(0.2, 0.0, 700_000_000).isSettling) + } + + @Test + fun `movement re-enters settling and resets the dwell`() { + val detector = SettlingDetector() + detector.update(0.0, 0.0, 0) + detector.update(0.0, 0.0, 100_000_000) + detector.update(0.0, 0.0, 600_000_000) + assertFalse(detector.update(0.0, 0.0, 700_000_000).isSettling) + + assertTrue(detector.update(0.2, 0.0, 800_000_000).isSettling) + assertTrue(detector.update(0.2, 0.0, 1_200_000_000).isSettling) + } + + @Test + fun `mid band movement resets the quiet dwell without leaving settling`() { + val detector = SettlingDetector() + + detector.update(0.0, 0.0, 0) + detector.update(0.0, 0.0, 100_000_000) // quiet dwell starts + detector.update(0.08, 0.0, 300_000_000) // 0.4°/s: between exit and enter + assertTrue(detector.update(0.08, 0.0, 600_000_000).isSettling) + assertTrue(detector.update(0.08, 0.0, 1_000_000_000).isSettling) + assertFalse(detector.update(0.08, 0.0, 1_100_000_000).isSettling) + } +} diff --git a/app/src/test/java/com/onthelevel/core/sensors/StableSurfaceCaptureTest.kt b/app/src/test/java/com/onthelevel/core/sensors/StableSurfaceCaptureTest.kt new file mode 100644 index 0000000..1ea1d03 --- /dev/null +++ b/app/src/test/java/com/onthelevel/core/sensors/StableSurfaceCaptureTest.kt @@ -0,0 +1,42 @@ +package com.onthelevel.core.sensors + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class StableSurfaceCaptureTest { + + private fun sample( + pitch: Double, + roll: Double, + timestampMillis: Long, + tilt: Double = 1.0, + settling: Boolean = false, + ) = SurfaceCalibrationSample(pitch, roll, tilt, timestampMillis * 1_000_000, settling) + + @Test + fun `captures the mean of a continuous settled one point five second window`() { + val capture = StableSurfaceCapture() + + assertNull(capture.add(sample(0.2, -0.1, 0))) + assertNull(capture.add(sample(0.4, -0.3, 750))) + val result = capture.add(sample(0.6, -0.5, 1_500)) + + requireNotNull(result) + assertEquals(0.4, result.pitchDegrees, 1e-9) + assertEquals(-0.3, result.rollDegrees, 1e-9) + } + + @Test + fun `motion or excessive tilt resets the capture window`() { + val capture = StableSurfaceCapture() + + assertNull(capture.add(sample(0.2, 0.0, 0))) + assertNull(capture.add(sample(0.2, 0.0, 750, settling = true))) + assertNull(capture.add(sample(0.2, 0.0, 1_000))) + assertNull(capture.add(sample(0.2, 0.0, 1_500, tilt = 5.1))) + assertNull(capture.add(sample(0.2, 0.0, 1_750))) + assertNull(capture.add(sample(0.2, 0.0, 2_500))) + requireNotNull(capture.add(sample(0.2, 0.0, 3_250))) + } +} diff --git a/app/src/test/java/com/onthelevel/core/sensors/SurfaceCalibrationValidationTest.kt b/app/src/test/java/com/onthelevel/core/sensors/SurfaceCalibrationValidationTest.kt new file mode 100644 index 0000000..1071255 --- /dev/null +++ b/app/src/test/java/com/onthelevel/core/sensors/SurfaceCalibrationValidationTest.kt @@ -0,0 +1,20 @@ +package com.onthelevel.core.sensors + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SurfaceCalibrationValidationTest { + + @Test + fun `accepts a combined bias at or below three degrees`() { + assertTrue(SurfaceCalibrationValidation.isAcceptable(SurfaceCalibration(3.0, 0.0))) + assertTrue(SurfaceCalibrationValidation.isAcceptable(SurfaceCalibration(1.8, 2.4))) + } + + @Test + fun `rejects a combined bias above three degrees`() { + assertFalse(SurfaceCalibrationValidation.isAcceptable(SurfaceCalibration(3.01, 0.0))) + assertFalse(SurfaceCalibrationValidation.isAcceptable(SurfaceCalibration(2.5, 2.5))) + } +}