diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 992120a0d..9d0e1f385 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -37,7 +37,7 @@ kotlin { withHostTest {} } - // iOS target (iosArm64 only - physical devices for distribution) + // iOS targets val xcf = XCFramework() iosArm64 { binaries.framework { @@ -52,6 +52,19 @@ kotlin { freeCompilerArgs += listOf("-Xadd-light-debug=enable") } } + iosSimulatorArm64 { + binaries.framework { + baseName = "shared" + isStatic = true + xcf.add(this) + // Link system frameworks required by shared module + linkerOpts("-framework", "HealthKit") + linkerOpts("-framework", "Speech") + } + binaries.all { + freeCompilerArgs += listOf("-Xadd-light-debug=enable") + } + } sourceSets { val commonMain by getting { @@ -179,11 +192,14 @@ kotlin { val iosArm64Main by getting val iosArm64Test by getting + val iosSimulatorArm64Main by getting + val iosSimulatorArm64Test by getting @Suppress("UNUSED_VARIABLE") val iosMain by creating { dependsOn(commonMain) iosArm64Main.dependsOn(this) + iosSimulatorArm64Main.dependsOn(this) dependencies { // SQLDelight Native Driver @@ -198,6 +214,7 @@ kotlin { val iosTest by creating { dependsOn(commonTest) iosArm64Test.dependsOn(this) + iosSimulatorArm64Test.dependsOn(this) dependencies { implementation(libs.sqldelight.native.driver) diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/PortalSyncAdapter.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/PortalSyncAdapter.kt index be22dc53f..40b16c537 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/PortalSyncAdapter.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/PortalSyncAdapter.kt @@ -580,7 +580,8 @@ object PortalSyncAdapter { repCountTiming = ex.repCountTiming.name, stopAtPosition = if (ex.stopAtTop) "TOP" else null, stallDetection = ex.stallDetectionEnabled, - eccentricLoad = if (ex.programMode == ProgramMode.Echo) { + eccentricLoad = if (ex.programMode == ProgramMode.Echo || + (ex.programMode == ProgramMode.OldSchool && ex.eccentricLoad.percentage > 100)) { ex.eccentricLoad.name } else { null diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/Models.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/Models.kt index 017635ac2..f22626158 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/Models.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/Models.kt @@ -370,6 +370,11 @@ data class WorkoutParameters( ) { /** True if this is an Echo workout */ val isEchoMode: Boolean get() = programMode == ProgramMode.Echo + + /** True if Old School mode has eccentric load > 100% (triggers 0x4E Echo packet dispatch). + * Gated on ProgramMode.OldSchool so switching to Pump/TUT/TUTBeast/EccentricOnly + * doesn't accidentally dispatch the 0x4E packet from a stale eccentric value. */ + val hasEccentricOverload: Boolean get() = programMode == ProgramMode.OldSchool && eccentricLoad.percentage > 100 } /** diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt index 58e308830..2573c50ae 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt @@ -31,6 +31,7 @@ import com.devil.phoenixproject.domain.model.BiomechanicsSetSummary import com.devil.phoenixproject.domain.model.BodyweightVariantOption import com.devil.phoenixproject.domain.model.CompletedSet import com.devil.phoenixproject.domain.model.ConnectionStatus +import com.devil.phoenixproject.domain.model.EchoLevel import com.devil.phoenixproject.domain.model.FiveThreeOneRoutineDetector import com.devil.phoenixproject.domain.model.HapticEvent import com.devil.phoenixproject.domain.model.IntegrationProvider @@ -430,8 +431,8 @@ class ActiveSessionEngine( // deload after 1.25s below 40 mm/s — Issue #553), so DELOAD_OCCURRED fires // routinely mid-set as the athlete fatigues. It is NOT a cable-release // signal in Echo mode and must never arm the auto-stop stall timer. - if (params.isEchoMode) { - Logger.d("DELOAD_OCCURRED ignored - Echo mode (deload windows define Echo levels)") + if (params.isEchoMode || params.hasEccentricOverload) { + Logger.d("DELOAD_OCCURRED ignored - eccentric overload active (Echo or Old School+ecc)") return@collect } if (!isWarmupGateOpenForAutoStop()) { @@ -1843,8 +1844,8 @@ class ActiveSessionEngine( val params = coordinator._workoutParameters.value if (!params.isJustLift) return - val eccentricLoadPct = if (params.isEchoMode) params.eccentricLoad.percentage else 100 - val echoLevelVal = if (params.isEchoMode) params.echoLevel.levelValue else 0 + val eccentricLoadPct = params.eccentricLoad.percentage + val echoLevelVal = params.echoLevel.levelValue try { val defaults = JustLiftDefaultsDocument( @@ -1883,8 +1884,8 @@ class ActiveSessionEngine( val exerciseId = currentExercise.exercise.id ?: return val isEchoExercise = currentExercise.programMode == ProgramMode.Echo - val eccentricLoadPct = if (isEchoExercise) currentExercise.eccentricLoad.percentage else 100 - val echoLevelVal = if (isEchoExercise) currentExercise.echoLevel.levelValue else 0 + val eccentricLoadPct = currentExercise.eccentricLoad.percentage + val echoLevelVal = currentExercise.echoLevel.levelValue try { val setReps = currentExercise.setReps.ifEmpty { listOf(10) } @@ -2831,10 +2832,11 @@ class ActiveSessionEngine( } } - val commandValidation = if (bleParams.isEchoMode) { + val hasEccentricOverload = bleParams.hasEccentricOverload + val commandValidation = if (bleParams.isEchoMode || hasEccentricOverload) { WorkoutCommandValidator.validateEchoControl( - level = bleParams.echoLevel, - warmupReps = bleParams.warmupReps, + level = if (bleParams.isEchoMode) bleParams.echoLevel else EchoLevel.HARDER, + warmupReps = if (bleParams.isEchoMode) bleParams.warmupReps else Constants.DEFAULT_WARMUP_REPS, targetReps = bleParams.reps, isJustLift = isJustLiftMode || bleParams.isJustLift, isAMRAP = bleParams.isAMRAP, @@ -2849,10 +2851,10 @@ class ActiveSessionEngine( return@launch } - val command = if (bleParams.isEchoMode) { + val command = if (bleParams.isEchoMode || hasEccentricOverload) { BlePacketFactory.createEchoControl( - level = bleParams.echoLevel, - warmupReps = bleParams.warmupReps, + level = if (bleParams.isEchoMode) bleParams.echoLevel else EchoLevel.HARDER, + warmupReps = if (bleParams.isEchoMode) bleParams.warmupReps else Constants.DEFAULT_WARMUP_REPS, targetReps = bleParams.reps, isJustLift = isJustLiftMode || bleParams.isJustLift, isAMRAP = bleParams.isAMRAP, diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/RoutineFlowManager.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/RoutineFlowManager.kt index ccf74b3f1..06f73aa50 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/RoutineFlowManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/RoutineFlowManager.kt @@ -1064,7 +1064,7 @@ class RoutineFlowManager( adjustedReps = setReps, adjustedProgressionKg = progressionKg, echoLevel = if (exercise.programMode is ProgramMode.Echo) exercise.echoLevel else null, - eccentricLoadPercent = if (exercise.programMode is ProgramMode.Echo) exercise.eccentricLoad.percentage else null, + eccentricLoadPercent = if (exercise.programMode is ProgramMode.Echo || exercise.programMode is ProgramMode.OldSchool) exercise.eccentricLoad.percentage else null, ) // Issue #129: Determine if this specific set is AMRAP (null reps = AMRAP) @@ -1145,7 +1145,7 @@ class RoutineFlowManager( adjustedReps = adjustedReps, adjustedProgressionKg = progressionKg, echoLevel = if (exercise.programMode is ProgramMode.Echo) exercise.echoLevel else null, - eccentricLoadPercent = if (exercise.programMode is ProgramMode.Echo) exercise.eccentricLoad.percentage else null, + eccentricLoadPercent = if (exercise.programMode is ProgramMode.Echo || exercise.programMode is ProgramMode.OldSchool) exercise.eccentricLoad.percentage else null, ) // Issue #129: Check raw value for AMRAP - null reps in setReps list = AMRAP diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseEditBottomSheet.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseEditBottomSheet.kt index a49140d55..07f974d12 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseEditBottomSheet.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseEditBottomSheet.kt @@ -234,6 +234,7 @@ fun ExerciseEditBottomSheet( val isTutMode = showCableOnlyExerciseControls && (selectedMode is WorkoutMode.TUT || selectedMode is WorkoutMode.TUTBeast) val isEchoMode = showCableOnlyExerciseControls && selectedMode is WorkoutMode.Echo + val isOldSchool = showCableOnlyExerciseControls && selectedMode is WorkoutMode.OldSchool LaunchedEffect(rackItems, defaultRackItemIds) { val enabledRackIds = rackItems.filter { it.enabled }.map { it.id }.toSet() @@ -414,12 +415,14 @@ fun ExerciseEditBottomSheet( } } - // Echo Mode options - if (isEchoMode) { + // Echo Mode options — EccentricLoad visible for Echo + Old School; EchoLevel Echo-only + if (isEchoMode || isOldSchool) { EccentricLoadSelector( eccentricLoad = eccentricLoad, onLoadChange = viewModel::onEccentricLoadChange, ) + } + if (isEchoMode) { EchoLevelSelector( level = echoLevel, onLevelChange = viewModel::onEchoLevelChange, diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/JustLiftScreen.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/JustLiftScreen.kt index f7514b88b..71781ec19 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/JustLiftScreen.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/JustLiftScreen.kt @@ -211,10 +211,8 @@ fun JustLiftScreen(navController: NavController, viewModel: MainViewModel, theme } LaunchedEffect(workoutParameters.programMode) { - if (workoutParameters.isEchoMode) { - eccentricLoad = workoutParameters.eccentricLoad - echoLevel = workoutParameters.echoLevel - } + eccentricLoad = workoutParameters.eccentricLoad + echoLevel = workoutParameters.echoLevel } // Navigate to ActiveWorkout when workout becomes active @@ -582,7 +580,10 @@ fun JustLiftScreen(navController: NavController, viewModel: MainViewModel, theme } val isEchoMode = selectedMode is WorkoutMode.Echo - if (isEchoMode) { + val isOldSchool = selectedMode is WorkoutMode.OldSchool + val showEccentricLoad = isEchoMode || isOldSchool + + if (showEccentricLoad) { Card( modifier = flexibleBodyModifier, colors = CardDefaults.cardColors( @@ -645,48 +646,51 @@ fun JustLiftScreen(navController: NavController, viewModel: MainViewModel, theme overflow = TextOverflow.Ellipsis, ) - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)) + // EchoLevel selector — only for Echo mode + if (isEchoMode) { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)) - Text( - stringResource(Res.string.echo_level), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface, - ) + Text( + stringResource(Res.string.echo_level), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) - if (useCompactAccessibility) { - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - EchoLevel.entries.forEach { level -> - FilterChip( - selected = echoLevel == level, - onClick = { - echoLevel = level - selectedMode = WorkoutMode.Echo(level) - }, - label = { Text(echoLevelLabel(level)) }, - ) + if (useCompactAccessibility) { + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + EchoLevel.entries.forEach { level -> + FilterChip( + selected = echoLevel == level, + onClick = { + echoLevel = level + selectedMode = WorkoutMode.Echo(level) + }, + label = { Text(echoLevelLabel(level)) }, + ) + } } - } - } else { - SingleChoiceSegmentedButtonRow( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp), - ) { - EchoLevel.entries.forEachIndexed { index, level -> - SegmentedButton( - shape = SegmentedButtonDefaults.itemShape(index = index, count = EchoLevel.entries.size), - onClick = { - echoLevel = level - selectedMode = WorkoutMode.Echo(level) - }, - selected = echoLevel == level, - ) { - Text(echoLevelLabel(level), maxLines = 1) + } else { + SingleChoiceSegmentedButtonRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp), + ) { + EchoLevel.entries.forEachIndexed { index, level -> + SegmentedButton( + shape = SegmentedButtonDefaults.itemShape(index = index, count = EchoLevel.entries.size), + onClick = { + echoLevel = level + selectedMode = WorkoutMode.Echo(level) + }, + selected = echoLevel == level, + ) { + Text(echoLevelLabel(level), maxLines = 1) + } } } } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/RoutineOverviewScreen.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/RoutineOverviewScreen.kt index a05c8810a..ac70d90c1 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/RoutineOverviewScreen.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/RoutineOverviewScreen.kt @@ -333,6 +333,7 @@ fun RoutineOverviewScreen(navController: NavController, viewModel: MainViewModel adjustedReps = adjustments.reps, isAMRAP = exercise.isAMRAP, isEchoMode = exercise.programMode is ProgramMode.Echo, + isOldSchool = exercise.programMode is ProgramMode.OldSchool, echoLevel = adjustments.echoLevel, eccentricLoadPercent = adjustments.eccentricLoadPercent, sizing = overviewSizing, @@ -552,6 +553,7 @@ private fun ExerciseOverviewCard( adjustedReps: Int, isAMRAP: Boolean, isEchoMode: Boolean, + isOldSchool: Boolean = false, echoLevel: EchoLevel, eccentricLoadPercent: Int, sizing: RoutineOverviewSizing, @@ -667,18 +669,22 @@ private fun ExerciseOverviewCard( ) if (isEchoMode) { - // Echo mode: Show Echo Level + Eccentric Load + Reps EchoLevelPillSelector( selectedLevel = echoLevel, onLevelChange = onEchoLevelChange, ) + } + // Eccentric Load slider — visible for Echo and Old School modes + if (isEchoMode || isOldSchool) { OverviewEccentricLoadSlider( percent = eccentricLoadPercent, onPercentChange = onEccentricLoadChange, ) + } - // Reps for Echo mode + // Reps adjuster for Echo mode only + if (isEchoMode) { if (!isAMRAP) { SliderWithButtons( value = adjustedReps.toFloat(), diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/SetReadyScreen.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/SetReadyScreen.kt index 37eabed3a..a72009ea3 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/SetReadyScreen.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/SetReadyScreen.kt @@ -156,6 +156,7 @@ fun SetReadyScreen(navController: NavController, viewModel: MainViewModel, exerc var pendingOverridesToSave by remember { mutableStateOf>(emptyMap()) } val isEchoMode = currentExercise.programMode is ProgramMode.Echo + val isOldSchool = currentExercise.programMode is ProgramMode.OldSchool val isAMRAP = currentExercise.isAMRAP // #635: explicit stored flag with equipment-derivation fallback @@ -626,7 +627,7 @@ fun SetReadyScreen(navController: NavController, viewModel: MainViewModel, exerc verticalArrangement = Arrangement.spacedBy(Spacing.medium), ) { Text( - if (isEchoMode) "ECHO SETTINGS" else "SET CONFIGURATION", + if (isEchoMode) "ECHO SETTINGS" else if (isOldSchool) "OLD SCHOOL SETTINGS" else "SET CONFIGURATION", style = labelAllCaps.copy(fontWeight = FontWeight.Bold), color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -637,14 +638,18 @@ fun SetReadyScreen(navController: NavController, viewModel: MainViewModel, exerc selectedLevel = setReadyState.echoLevel ?: EchoLevel.HARDER, onLevelChange = { viewModel.updateSetReadyEchoLevel(it) }, ) + } - // Eccentric Load slider - matching RestTimerCard style + // Eccentric Load slider — visible for Echo and Old School modes + if (isEchoMode || isOldSchool) { SetReadyEccentricLoadSlider( percent = setReadyState.eccentricLoadPercent ?: 100, onPercentChange = { viewModel.updateSetReadyEccentricLoad(it) }, ) + } - // Reps adjuster for Echo mode too + // Reps adjuster for Echo mode only + if (isEchoMode) { if (!isAMRAP) { SliderWithButtons( value = setReadyState.adjustedReps.toFloat(), diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/model/WorkoutParametersTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/model/WorkoutParametersTest.kt index 1a0aaa978..570c5b825 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/model/WorkoutParametersTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/model/WorkoutParametersTest.kt @@ -137,4 +137,63 @@ class WorkoutParametersTest { assertEquals(2.5f, params.progressionRegressionKg) } + + // Issue #674: Old School eccentric overload tests + + @Test + fun `hasEccentricOverload true for Old School with LOAD_130`() { + val params = WorkoutParameters( + programMode = ProgramMode.OldSchool, + reps = 10, + eccentricLoad = EccentricLoad.LOAD_130, + ) + + assertTrue(params.hasEccentricOverload) + } + + @Test + fun `hasEccentricOverload false for Old School with LOAD_100`() { + val params = WorkoutParameters( + programMode = ProgramMode.OldSchool, + reps = 10, + eccentricLoad = EccentricLoad.LOAD_100, + ) + + assertFalse(params.hasEccentricOverload) + } + + @Test + fun `hasEccentricOverload false for Echo with LOAD_100`() { + val params = WorkoutParameters( + programMode = ProgramMode.Echo, + reps = 10, + eccentricLoad = EccentricLoad.LOAD_100, + ) + + assertFalse(params.hasEccentricOverload) + } + + @Test + fun `hasEccentricOverload false for Echo with LOAD_130`() { + val params = WorkoutParameters( + programMode = ProgramMode.Echo, + reps = 10, + eccentricLoad = EccentricLoad.LOAD_130, + ) + + // Echo has its own overload mechanism (isEchoMode); hasEccentricOverload is OldSchool-only + assertFalse(params.hasEccentricOverload) + } + + @Test + fun `hasEccentricOverload false for Pump with LOAD_130 after mode switch`() { + val params = WorkoutParameters( + programMode = ProgramMode.Pump, + reps = 10, + eccentricLoad = EccentricLoad.LOAD_130, + ) + + // Mode switch from Old School+130% to Pump must NOT trigger 0x4E dispatch + assertFalse(params.hasEccentricOverload) + } } diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/SetReadyScreenScrollWiringTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/SetReadyScreenScrollWiringTest.kt index 69466bdd4..acd30e344 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/SetReadyScreenScrollWiringTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/SetReadyScreenScrollWiringTest.kt @@ -248,7 +248,10 @@ class SetReadyScreenScrollWiringTest { @Test fun setReadyConfiguration_appearsBeforeVideo() { val src = readSetReadyScreenSource() - val configIdx = src.indexOf("if (isEchoMode) \"ECHO SETTINGS\" else \"SET CONFIGURATION\"") + // Issue #674: Old School mode now has its own settings label + val configIdx = src.indexOf("if (isEchoMode) \"ECHO SETTINGS\" else if (isOldSchool) \"OLD SCHOOL SETTINGS\" else \"SET CONFIGURATION\"") + .takeIf { it >= 0 } + ?: src.indexOf("if (isEchoMode) \"ECHO SETTINGS\" else \"SET CONFIGURATION\"") val rackIdx = src.indexOf("testTag(SetReadyTestTags.RACK_CARD)") val videoIdx = src.indexOf("Video thumbnail stays available") diff --git a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt new file mode 100644 index 000000000..45f066257 --- /dev/null +++ b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt @@ -0,0 +1,1821 @@ +package com.devil.phoenixproject.data.repository + +import com.devil.phoenixproject.data.ble.DiagnosticPacket +import com.devil.phoenixproject.data.ble.MonitorDataProcessor +import com.devil.phoenixproject.data.ble.parseDiagnosticPacket +import com.devil.phoenixproject.data.ble.parseHeuristicPacket +import com.devil.phoenixproject.data.ble.parseMonitorPacket +import com.devil.phoenixproject.data.ble.parseRepPacket +import com.devil.phoenixproject.data.ble.toVitruvianHex +import com.devil.phoenixproject.domain.model.ConnectionState +import com.devil.phoenixproject.domain.model.HeuristicPhaseStatistics +import com.devil.phoenixproject.domain.model.HeuristicStatistics +import com.devil.phoenixproject.domain.model.WorkoutMetric +import com.devil.phoenixproject.domain.model.WorkoutParameters +import kotlin.coroutines.cancellation.CancellationException +import kotlin.math.PI +import kotlin.math.sin +import kotlin.time.Clock +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.yield +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.atomicfu.atomic +import kotlinx.atomicfu.locks.reentrantLock +import kotlinx.atomicfu.locks.withLock + +enum class PhantomRawPacketKind { + MONITOR, + REP, + DIAGNOSTIC, + HEURISTIC, +} + +internal data class PhantomWorkoutProgram( + val warmupReps: Int, + val workingReps: Int?, + val weightPerCableKg: Float, +) + +data class PhantomBleConfig( + val loadScale: Float = 1f, + val velocityScale: Double = 1.0, + val positionScale: Float = 1f, + val repDelayMs: Long = 750L, + val autoCompleteFixedRepSets: Boolean = true, + val defaultEchoLoadKg: Float = 20f, +) { + init { + require(loadScale > 0f) { "loadScale must be > 0" } + require(velocityScale > 0.0) { "velocityScale must be > 0" } + require(positionScale > 0f) { "positionScale must be > 0" } + require(repDelayMs >= 100L) { "repDelayMs must be >= 100" } + require(defaultEchoLoadKg > 0f) { "defaultEchoLoadKg must be > 0" } + } + + companion object { + val Default = PhantomBleConfig() + } +} + +/** + * Emulator-safe phantom Vitruvian machine. + * + * This implementation exercises the same app-level [BleRepository] surface as the real + * Kable-backed machine without touching Bluetooth hardware. It is intended for debug + * sandboxes, emulator bug reproduction, UI flow testing, and connection-log capture. + */ +class PhantomBleRepository( + private val logRepo: ConnectionLogRepository = ConnectionLogRepository.instance, + initialConfig: PhantomBleConfig = PhantomBleConfig.Default, +) : BleRepository { + private val repositoryJob = SupervisorJob() + private val scope = CoroutineScope(repositoryJob + Dispatchers.Default) + + private val device = ScannedDevice(PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS, -42) + + private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) + override val connectionState: StateFlow = _connectionState.asStateFlow() + + private val _metricsFlow = MutableSharedFlow(extraBufferCapacity = 64) + override val metricsFlow: Flow = _metricsFlow.asSharedFlow() + + private val _scannedDevices = MutableStateFlow>(emptyList()) + override val scannedDevices: StateFlow> = _scannedDevices.asStateFlow() + + private val _handleDetection = MutableStateFlow(HandleDetection()) + override val handleDetection: StateFlow = _handleDetection.asStateFlow() + + private val _repEvents = MutableSharedFlow(extraBufferCapacity = 16) + override val repEvents: Flow = _repEvents.asSharedFlow() + + private val _handleState = MutableStateFlow(HandleState.WaitingForRest) + override val handleState: StateFlow = _handleState.asStateFlow() + + private val _deloadOccurredEvents = MutableSharedFlow(extraBufferCapacity = 4) + override val deloadOccurredEvents: Flow = _deloadOccurredEvents.asSharedFlow() + + private val _reconnectionRequested = MutableSharedFlow(extraBufferCapacity = 4) + override val reconnectionRequested: Flow = _reconnectionRequested.asSharedFlow() + + private val _heuristicData = MutableStateFlow(null) + override val heuristicData: StateFlow = _heuristicData.asStateFlow() + + private val _diagnostics = MutableStateFlow(null) + override val diagnostics: StateFlow = _diagnostics.asStateFlow() + + private val _discoModeActive = MutableStateFlow(false) + override val discoModeActive: StateFlow = _discoModeActive.asStateFlow() + + private var monitorProcessor: MonitorDataProcessor? = null + private var monitorProcessorConnectionGeneration: Long? = null + private var metricsJob: Job? = null + private var heuristicJob: Job? = null + private var repJob: Job? = null + private var repSimulationCompleted = false + private var diagnosticJob: Job? = null + private var heartbeatJob: Job? = null + private var workoutParams: WorkoutParameters? = null + private var workoutConnectionGeneration: Long? = null + private var currentWorkoutProgram: PhantomWorkoutProgram? = null + internal val currentProgram: PhantomWorkoutProgram? + get() = currentWorkoutProgram + private val _config = MutableStateFlow(initialConfig) + val config: StateFlow = _config.asStateFlow() + private var ticks = 0L + private var lastColorSchemeIndex = 0 + private val lifecycleLock = reentrantLock() + private val terminal = atomic(false) + private val connectionAttemptGeneration = atomic(0L) + private var lifecycleCleanupInProgress = false + private var connectionAttemptReservationActive = false + private var activePollingConnectionGeneration: Long? = null + private var handleStateControlGeneration = 0L + private var handleDetectionControlGeneration = 0L + private var discoControlGeneration = 0L + private var metricsGeneration = 0L + private var heuristicGeneration = 0L + private var repGeneration = 0L + private var diagnosticGeneration = 0L + private var heartbeatGeneration = 0L + + override suspend fun startScanning(): Result { + val attemptGeneration = beginConnectionAttempt(ConnectionState.Scanning) + ?: return Result.failure(IllegalStateException("Phantom repository is shut down")) + return try { + startScanning(attemptGeneration) + } catch (error: CancellationException) { + invalidateCancelledConnectionAttempt(attemptGeneration, ConnectionState.Scanning) + throw error + } + } + + private suspend fun startScanning(attemptGeneration: Long): Result { + if (!publishConnectionState(attemptGeneration, ConnectionState.Scanning) { + if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { + false + } else { + logRepo.info(LogEventType.SCAN_START, "Starting phantom Vitruvian scan") + true + } + }) { + return Result.failure(IllegalStateException("Phantom scan attempt invalidated")) + } + delay(150) + if (!publishScannedDevices(attemptGeneration, listOf(device))) { + return Result.failure(IllegalStateException("Phantom scan attempt invalidated")) + } + yield() + return Result.success(Unit) + } + + override suspend fun stopScanning() { + lifecycleLock.withLock { + if (terminal.value || lifecycleCleanupInProgress) { + return@withLock + } + if (_connectionState.value != ConnectionState.Scanning && + _connectionState.value != ConnectionState.Connecting + ) { + return@withLock + } + val cleanupGeneration = connectionAttemptGeneration.incrementAndGet() + _connectionState.value = ConnectionState.Disconnected + if (terminal.value || connectionAttemptGeneration.value != cleanupGeneration) { + return@withLock + } + logRepo.info(LogEventType.SCAN_STOP, "Stopped phantom Vitruvian scan") + if (terminal.value || connectionAttemptGeneration.value != cleanupGeneration) { + return@withLock + } + } + } + + override suspend fun connect(device: ScannedDevice): Result { + val attemptGeneration = beginConnectionAttempt(ConnectionState.Connecting) + ?: return Result.failure(IllegalStateException("Phantom repository is shut down")) + return try { + connect(device, attemptGeneration) + } catch (error: CancellationException) { + invalidateCancelledConnectionAttempt(attemptGeneration, ConnectionState.Connecting) + throw error + } + } + + private suspend fun connect(device: ScannedDevice, attemptGeneration: Long): Result { + if (!publishConnectionState(attemptGeneration, ConnectionState.Connecting) { + logRepo.info(LogEventType.CONNECT_START, "Connecting to phantom Vitruvian", device.name, device.address) + true + }) { + return Result.failure(IllegalStateException("Phantom connection attempt invalidated")) + } + delay(250) + if (!completeConnection(attemptGeneration, device)) { + return Result.failure(IllegalStateException("Phantom connection attempt invalidated")) + } + yield() + return Result.success(Unit) + } + + override suspend fun cancelConnection() { + lifecycleLock.withLock { + if (terminal.value || lifecycleCleanupInProgress) { + return@withLock + } + if (_connectionState.value == ConnectionState.Connecting) { + val cleanupGeneration = connectionAttemptGeneration.incrementAndGet() + _connectionState.value = ConnectionState.Disconnected + if (terminal.value || connectionAttemptGeneration.value != cleanupGeneration) { + return@withLock + } + logRepo.warning(LogEventType.DISCONNECT, "Cancelled phantom connection") + if (terminal.value || connectionAttemptGeneration.value != cleanupGeneration) { + return@withLock + } + } + } + } + + override suspend fun disconnect() { + lifecycleLock.withLock { + if (terminal.value || lifecycleCleanupInProgress) { + return@withLock + } + lifecycleCleanupInProgress = true + val cleanupGeneration = connectionAttemptGeneration.incrementAndGet() + try { + if (terminal.value || connectionAttemptGeneration.value != cleanupGeneration) { + return@withLock + } + logRepo.info(LogEventType.DISCONNECT, "Disconnected phantom Vitruvian", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) + if (terminal.value || connectionAttemptGeneration.value != cleanupGeneration) { + return@withLock + } + teardownConnection() + } finally { + lifecycleCleanupInProgress = false + } + } + } + + override suspend fun shutdown() { + lifecycleLock.withLock { + if (terminal.value) { + return@withLock + } + if (lifecycleCleanupInProgress) { + terminal.value = true + teardownConnection(markTerminal = true) + return@withLock + } + terminal.value = true + lifecycleCleanupInProgress = true + connectionAttemptGeneration.incrementAndGet() + try { + logRepo.info(LogEventType.DISCONNECT, "Disconnected phantom Vitruvian", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) + teardownConnection(markTerminal = true) + } finally { + lifecycleCleanupInProgress = false + } + } + repositoryJob.cancel() + } + + override suspend fun scanAndConnect(timeoutMs: Long): Result { + if (timeoutMs <= 0L) { + return Result.failure(IllegalArgumentException("timeoutMs must be > 0")) + } + + val attemptGeneration = beginConnectionAttempt(ConnectionState.Scanning) + ?: return Result.failure(IllegalStateException("Phantom repository is shut down")) + + val completed = try { + withTimeoutOrNull(timeoutMs) { + val scanResult = startScanning(attemptGeneration) + if (scanResult.isFailure) { + return@withTimeoutOrNull scanResult + } + connect(device, attemptGeneration) + } + } catch (error: CancellationException) { + invalidateCancelledConnectionAttempt(attemptGeneration) + throw error + } + if (completed != null) { + return completed + } + + return lifecycleLock.withLock { + if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { + Result.failure(IllegalStateException("Phantom scan and connect attempt invalidated")) + } else { + val expectedPostTeardownGeneration = attemptGeneration + 1L + lifecycleCleanupInProgress = true + try { + teardownConnection() + } finally { + lifecycleCleanupInProgress = false + } + if (terminal.value || connectionAttemptGeneration.value != expectedPostTeardownGeneration) { + Result.failure(IllegalStateException("Phantom scan and connect attempt invalidated")) + } else { + logRepo.error( + LogEventType.ERROR, + "Phantom scan and connect timed out", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + "timeoutMs=$timeoutMs", + ) + if (terminal.value || connectionAttemptGeneration.value != expectedPostTeardownGeneration) { + Result.failure(IllegalStateException("Phantom scan and connect attempt invalidated")) + } else { + _reconnectionRequested.tryEmit( + ReconnectionRequest( + deviceName = PHANTOM_DEVICE_NAME, + deviceAddress = PHANTOM_DEVICE_ADDRESS, + reason = "connection_timeout", + timestamp = Clock.System.now().toEpochMilliseconds(), + ), + ) + if (terminal.value || connectionAttemptGeneration.value != expectedPostTeardownGeneration) { + Result.failure(IllegalStateException("Phantom scan and connect attempt invalidated")) + } else { + Result.failure(IllegalStateException("Phantom scan and connect timed out after ${timeoutMs}ms")) + } + } + } + } + } + } + + override suspend fun setColorScheme(schemeIndex: Int): Result { + return lifecycleLock.withLock { + if (terminal.value || lifecycleCleanupInProgress || connectionAttemptReservationActive) { + return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) + } + val expectedConnectionGeneration = connectionAttemptGeneration.value + lastColorSchemeIndex = schemeIndex + logRepo.info(LogEventType.COMMAND_SENT, "Phantom color scheme set", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS, "scheme=$schemeIndex") + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) + } + Result.success(Unit) + } + } + + override suspend fun sendWorkoutCommand(command: ByteArray): Result { + return lifecycleLock.withLock { + if (terminal.value || lifecycleCleanupInProgress || connectionAttemptReservationActive) { + return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) + } + val expectedConnectionGeneration = connectionAttemptGeneration.value + logRepo.info( + LogEventType.COMMAND_SENT, + "Phantom received raw workout command", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + command.joinToString(" ") { it.toUByte().toString(16).padStart(2, '0') }, + ) + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) + } + decodeWorkoutProgram(command)?.let { program -> + currentWorkoutProgram = program + logRepo.info( + LogEventType.COMMAND_SENT, + "Phantom parsed workout command", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + "warmupReps=${program.warmupReps}; workingReps=${program.workingReps}; weightPerCableKg=${program.weightPerCableKg}", + ) + } + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) + } + Result.success(Unit) + } + } + + private fun decodeWorkoutProgram(command: ByteArray): PhantomWorkoutProgram? { + if (command.size >= REGULAR_PROGRAM_PACKET_SIZE && readUInt32LittleEndian(command, 0) == REGULAR_PROGRAM_OPCODE) { + val totalReps = command[0x04].toInt() and 0xFF + val warmupReps = command[0x05].toInt() and 0xFF + return PhantomWorkoutProgram( + warmupReps = warmupReps, + workingReps = totalReps.takeUnless { it == UNLIMITED_REPS }?.minus(warmupReps), + weightPerCableKg = readFloat32LittleEndian(command, 0x58), + ) + } + + if (command.size >= ECHO_PROGRAM_PACKET_SIZE && readUInt32LittleEndian(command, 0) == ECHO_PROGRAM_OPCODE) { + val warmupReps = command[0x04].toInt() and 0xFF + val targetReps = command[0x05].toInt() and 0xFF + return PhantomWorkoutProgram( + warmupReps = warmupReps, + workingReps = targetReps.takeUnless { it == UNLIMITED_REPS }, + weightPerCableKg = _config.value.defaultEchoLoadKg, + ) + } + + return null + } + + private fun readUInt32LittleEndian(bytes: ByteArray, offset: Int): Int = + (bytes[offset].toInt() and 0xFF) or + ((bytes[offset + 1].toInt() and 0xFF) shl 8) or + ((bytes[offset + 2].toInt() and 0xFF) shl 16) or + ((bytes[offset + 3].toInt() and 0xFF) shl 24) + + private fun readFloat32LittleEndian(bytes: ByteArray, offset: Int): Float = + Float.fromBits(readUInt32LittleEndian(bytes, offset)) + + override suspend fun sendInitSequence(): Result { + return lifecycleLock.withLock { + if (terminal.value || lifecycleCleanupInProgress || connectionAttemptReservationActive) { + return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) + } + val expectedConnectionGeneration = connectionAttemptGeneration.value + logRepo.info(LogEventType.COMMAND_SENT, "Phantom init sequence accepted", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) + } + Result.success(Unit) + } + } + + override suspend fun startWorkout(params: WorkoutParameters): Result { + return lifecycleLock.withLock { + if (terminal.value || lifecycleCleanupInProgress || connectionAttemptReservationActive) { + return@withLock Result.failure( + IllegalStateException( + if (connectionAttemptReservationActive) { + "Phantom connection attempt is being reserved" + } else { + "Phantom repository is shut down" + }, + ), + ) + } + if (_connectionState.value !is ConnectionState.Connected) { + return@withLock Result.failure(IllegalStateException("Phantom workout requires an active connection")) + } + val expectedConnectionGeneration = connectionAttemptGeneration.value + if (_discoModeActive.value) { + stopDiscoMode() + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration || + _discoModeActive.value + ) { + return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) + } + } + workoutParams = params + workoutConnectionGeneration = expectedConnectionGeneration + _handleState.value = HandleState.Grabbed + if (!workoutHandoffIsCurrent(params, expectedConnectionGeneration)) { + rollbackWorkoutHandoff(params, expectedConnectionGeneration) + return@withLock Result.failure(IllegalStateException("Phantom workout handoff invalidated")) + } + logRepo.info( + LogEventType.COMMAND_SENT, + "Phantom workout started", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + "mode=${params.programMode}; reps=${params.reps}; weightPerCableKg=${params.weightPerCableKg}; justLift=${params.isJustLift}", + ) + if (!workoutHandoffIsCurrent(params, expectedConnectionGeneration)) { + rollbackWorkoutHandoff(params, expectedConnectionGeneration) + return@withLock Result.failure(IllegalStateException("Phantom workout handoff invalidated")) + } + startMetrics( + activeWorkout = true, + expectedConnectionGeneration = expectedConnectionGeneration, + ) + if (!workoutHandoffIsCurrent(params, expectedConnectionGeneration)) { + rollbackWorkoutHandoff(params, expectedConnectionGeneration) + return@withLock Result.failure(IllegalStateException("Phantom workout handoff invalidated")) + } + if (!startHeuristicGeneration( + activeWorkout = true, + expectedConnectionGeneration = expectedConnectionGeneration, + ) && + !currentConnectedProducerOwnsConnection(expectedConnectionGeneration, heuristicJob) + ) { + rollbackWorkoutHandoff(params, expectedConnectionGeneration) + return@withLock Result.failure(IllegalStateException("Phantom workout handoff invalidated")) + } + if (!workoutHandoffIsCurrent(params, expectedConnectionGeneration)) { + rollbackWorkoutHandoff(params, expectedConnectionGeneration) + return@withLock Result.failure(IllegalStateException("Phantom workout handoff invalidated")) + } + startRepSimulation( + params = params, + expectedConnectionGeneration = expectedConnectionGeneration, + ) + if (!workoutHandoffIsCurrent(params, expectedConnectionGeneration)) { + rollbackWorkoutHandoff(params, expectedConnectionGeneration) + return@withLock Result.failure(IllegalStateException("Phantom workout handoff invalidated")) + } + Result.success(Unit) + } + } + + override suspend fun stopWorkout(): Result { + return lifecycleLock.withLock { + if (terminal.value || lifecycleCleanupInProgress || connectionAttemptReservationActive) { + return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) + } + if (_connectionState.value !is ConnectionState.Connected) { + return@withLock Result.failure(IllegalStateException("Phantom workout requires an active connection")) + } + lifecycleCleanupInProgress = true + try { + val expectedConnectionGeneration = connectionAttemptGeneration.incrementAndGet() + logRepo.info(LogEventType.COMMAND_SENT, "Phantom workout stopped", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) + } + stopJobs() + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) + } + activePollingConnectionGeneration = null + workoutParams = null + workoutConnectionGeneration = null + _handleState.value = HandleState.Released + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) + } + Result.success(Unit) + } finally { + lifecycleCleanupInProgress = false + } + } + } + + override suspend fun sendStopCommand(): Result { + return lifecycleLock.withLock { + if (terminal.value || lifecycleCleanupInProgress || connectionAttemptReservationActive) { + Result.failure(IllegalStateException("Phantom repository is shut down")) + } else { + val expectedConnectionGeneration = connectionAttemptGeneration.value + logRepo.info(LogEventType.COMMAND_SENT, "Phantom stop command accepted", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + Result.failure(IllegalStateException("Phantom repository is shut down")) + } else { + Result.success(Unit) + } + } + } + } + + override fun enableHandleDetection(enabled: Boolean) { + lifecycleLock.withLock { + if (terminal.value || lifecycleCleanupInProgress || connectionAttemptReservationActive) { + return@withLock + } + val expectedConnectionGeneration = connectionAttemptGeneration.value + handleStateControlGeneration += 1 + handleDetectionControlGeneration += 1 + val expectedHandleStateControlGeneration = handleStateControlGeneration + val expectedHandleDetectionControlGeneration = handleDetectionControlGeneration + _handleDetection.value = HandleDetection(leftDetected = enabled, rightDetected = enabled) + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration || + handleStateControlGeneration != expectedHandleStateControlGeneration || + handleDetectionControlGeneration != expectedHandleDetectionControlGeneration || + _handleDetection.value.leftDetected != enabled || + _handleDetection.value.rightDetected != enabled + ) { + return@withLock + } + if ( + workoutConnectionGeneration == expectedConnectionGeneration && + (_handleState.value == HandleState.Grabbed || workoutParams != null) + ) { + return@withLock + } + _handleState.value = if (enabled) HandleState.WaitingForRest else HandleState.Released + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration || + handleStateControlGeneration != expectedHandleStateControlGeneration || + handleDetectionControlGeneration != expectedHandleDetectionControlGeneration || + _handleDetection.value.leftDetected != enabled || + _handleDetection.value.rightDetected != enabled + ) { + return@withLock + } + logRepo.info(LogEventType.NOTIFICATION, "Phantom handle detection ${if (enabled) "enabled" else "disabled"}") + } + } + + override fun resetHandleState() { + lifecycleLock.withLock { + val expectedConnectionGeneration = connectionAttemptGeneration.value + if ( + terminal.value || + lifecycleCleanupInProgress || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration + ) { + return@withLock + } + handleStateControlGeneration += 1 + _handleState.value = HandleState.WaitingForRest + if ( + terminal.value || + lifecycleCleanupInProgress || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration + ) { + return@withLock + } + } + } + + override fun enableJustLiftWaitingMode() { + lifecycleLock.withLock { + if (terminal.value || lifecycleCleanupInProgress || connectionAttemptReservationActive) { + return@withLock + } + val expectedConnectionGeneration = connectionAttemptGeneration.value + handleStateControlGeneration += 1 + _handleState.value = HandleState.WaitingForRest + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration + ) { + return@withLock + } + logRepo.info(LogEventType.NOTIFICATION, "Phantom Just Lift waiting mode armed") + } + } + + override fun restartMonitorPolling() { + lifecycleLock.withLock { + if (!terminal.value && !lifecycleCleanupInProgress && !connectionAttemptReservationActive) { + val expectedConnectionGeneration = connectionAttemptGeneration.value + val activeWorkout = workoutParams != null || + activePollingConnectionGeneration == expectedConnectionGeneration + startMetrics( + activeWorkout = activeWorkout, + expectedConnectionGeneration = expectedConnectionGeneration, + ) + } + } + } + + override fun startActiveWorkoutPolling() { + lifecycleLock.withLock { + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptReservationActive || + _connectionState.value !is ConnectionState.Connected + ) { + return@withLock + } + val expectedConnectionGeneration = connectionAttemptGeneration.value + activePollingConnectionGeneration = expectedConnectionGeneration + workoutConnectionGeneration = expectedConnectionGeneration + _handleState.value = HandleState.Grabbed + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration + ) { + return@withLock + } + startMetrics( + activeWorkout = true, + expectedConnectionGeneration = expectedConnectionGeneration, + ) + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration + ) { + return@withLock + } + if (!startHeuristicGeneration( + activeWorkout = true, + expectedConnectionGeneration = expectedConnectionGeneration, + ) + ) { + return@withLock + } + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration + ) { + return@withLock + } + } + } + + override fun stopPolling() { + lifecycleLock.withLock { + if (terminal.value || lifecycleCleanupInProgress || connectionAttemptReservationActive) { + return@withLock + } + metricsGeneration += 1 + heuristicGeneration += 1 + repGeneration += 1 + diagnosticGeneration += 1 + heartbeatGeneration += 1 + metricsJob?.cancel() + heuristicJob?.cancel() + repJob?.cancel() + diagnosticJob?.cancel() + heartbeatJob?.cancel() + logRepo.info(LogEventType.HEARTBEAT, "Phantom polling stopped") + } + } + + override fun stopMonitorPollingOnly() { + lifecycleLock.withLock { + if (terminal.value || lifecycleCleanupInProgress || connectionAttemptReservationActive) { + return@withLock + } + metricsGeneration += 1 + metricsJob?.cancel() + logRepo.info(LogEventType.HEARTBEAT, "Phantom monitor polling stopped; diagnostics kept warm") + } + } + + override fun restartDiagnosticPolling() { + lifecycleLock.withLock { + if (!terminal.value && !lifecycleCleanupInProgress && !connectionAttemptReservationActive) { + val expectedConnectionGeneration = connectionAttemptGeneration.value + if (!startDiagnostics(expectedConnectionGeneration) || + terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration + ) { + return@withLock + } + startHeartbeat(expectedConnectionGeneration) + } + } + } + + override fun startDiscoMode() { + lifecycleLock.withLock { + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptReservationActive || + _connectionState.value !is ConnectionState.Connected || + workoutParams != null + ) { + return@withLock + } + val expectedConnectionGeneration = connectionAttemptGeneration.value + val expectedDiscoControlGeneration = ++discoControlGeneration + _discoModeActive.value = true + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration || + discoControlGeneration != expectedDiscoControlGeneration || + !_discoModeActive.value + ) { + return@withLock + } + logRepo.info(LogEventType.COMMAND_SENT, "Phantom disco mode started", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) + } + } + + override fun stopDiscoMode() { + lifecycleLock.withLock { + if (terminal.value || lifecycleCleanupInProgress || connectionAttemptReservationActive) { + return@withLock + } + val expectedConnectionGeneration = connectionAttemptGeneration.value + val expectedDiscoControlGeneration = ++discoControlGeneration + _discoModeActive.value = false + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration || + discoControlGeneration != expectedDiscoControlGeneration || + _discoModeActive.value + ) { + return@withLock + } + logRepo.info( + LogEventType.COMMAND_SENT, + "Phantom disco mode stopped", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + "restoredScheme=$lastColorSchemeIndex", + ) + } + } + + override fun setLastColorSchemeIndex(index: Int) { + lifecycleLock.withLock { + if (!terminal.value && !lifecycleCleanupInProgress && !connectionAttemptReservationActive) { + lastColorSchemeIndex = index + } + } + } + + /** + * Inject raw Vitruvian characteristic bytes into the same protocol parsers used by real BLE. + * This lets emulator-driven RCA reproduce packet-parser bugs without physical hardware. + */ + suspend fun injectRawPacket( + kind: PhantomRawPacketKind, + data: ByteArray, + hasOpcodePrefix: Boolean = false, + ): Result { + val expectedConnectionGeneration = lifecycleLock.withLock { + if (terminal.value || lifecycleCleanupInProgress || connectionAttemptReservationActive) { + null + } else { + connectionAttemptGeneration.value + } + } ?: return Result.failure(IllegalStateException("Phantom repository is shut down")) + + return try { + when (kind) { + PhantomRawPacketKind.MONITOR -> injectMonitorPacket(data, expectedConnectionGeneration) + PhantomRawPacketKind.REP -> injectRepPacket(data, hasOpcodePrefix, expectedConnectionGeneration) + PhantomRawPacketKind.DIAGNOSTIC -> injectDiagnosticPacket(data, expectedConnectionGeneration) + PhantomRawPacketKind.HEURISTIC -> injectHeuristicPacket(data, expectedConnectionGeneration) + } + if (lifecycleLock.withLock { + terminal.value || + lifecycleCleanupInProgress || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration + } + ) { + Result.failure(IllegalStateException("Phantom raw packet injection invalidated")) + } else { + Result.success(Unit) + } + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + lifecycleLock.withLock { + if (!terminal.value && + !lifecycleCleanupInProgress && + !connectionAttemptReservationActive && + connectionAttemptGeneration.value == expectedConnectionGeneration + ) { + logRepo.error( + LogEventType.ERROR, + "Phantom raw ${kind.name.lowercase()} packet rejected", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + "${error.message}; hex=${data.joinToString(" ") { it.toVitruvianHex() }}", + ) + } + } + Result.failure(error) + } + } + private suspend fun injectMonitorPacket(data: ByteArray, expectedConnectionGeneration: Long) { + lifecycleLock.withLock { + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration + ) { + return@withLock + } + val packet = parseMonitorPacket(data) + ?: error("monitor packet too short: ${data.size} bytes") + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration + ) { + return@withLock + } + val metric = monitorProcessorFor(expectedConnectionGeneration).process(packet) + ?: error("monitor packet parsed but was rejected by validation") + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration + ) { + return@withLock + } + _metricsFlow.tryEmit(metric) + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration + ) { + return@withLock + } + logRepo.info( + LogEventType.NOTIFICATION, + "Phantom injected raw monitor packet", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + "ticks=${metric.ticks}; load=${metric.totalLoad}; posA=${metric.positionA}; hex=${data.joinToString(" ") { it.toVitruvianHex() }}", + ) + } + } + + private suspend fun injectRepPacket( + data: ByteArray, + hasOpcodePrefix: Boolean, + expectedConnectionGeneration: Long, + ) { + lifecycleLock.withLock { + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration + ) { + return@withLock + } + val timestamp = Clock.System.now().toEpochMilliseconds() + val rep = parseRepPacket(data, hasOpcodePrefix, timestamp) + ?: error("rep packet too short: ${data.size} bytes") + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration + ) { + return@withLock + } + _repEvents.tryEmit(rep) + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration + ) { + return@withLock + } + logRepo.info( + LogEventType.REP_RECEIVED, + "Phantom injected raw rep packet", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + "top=${rep.topCounter}; complete=${rep.completeCounter}; legacy=${rep.isLegacyFormat}; hex=${data.joinToString(" ") { it.toVitruvianHex() }}", + ) + } + } + + private fun injectDiagnosticPacket(data: ByteArray, expectedConnectionGeneration: Long) { + lifecycleLock.withLock { + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration + ) { + return@withLock + } + val timestamp = Clock.System.now().toEpochMilliseconds() + val diagnostic = parseDiagnosticPacket(data) + ?: error("diagnostic packet too short: ${data.size} bytes") + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration + ) { + return@withLock + } + _diagnostics.value = diagnostic.copy(receivedAtMillis = timestamp) + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration + ) { + return@withLock + } + logRepo.info( + LogEventType.DIAGNOSTIC, + "Phantom injected raw diagnostic packet", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + "faults=${diagnostic.faultWords}; temps=${diagnostic.temperatures}; hex=${data.joinToString(" ") { it.toVitruvianHex() }}", + ) + } + } + + private fun injectHeuristicPacket(data: ByteArray, expectedConnectionGeneration: Long) { + lifecycleLock.withLock { + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration + ) { + return@withLock + } + val timestamp = Clock.System.now().toEpochMilliseconds() + val heuristic = parseHeuristicPacket(data, timestamp) + ?: error("heuristic packet too short: ${data.size} bytes") + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration + ) { + return@withLock + } + _heuristicData.value = heuristic + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration + ) { + return@withLock + } + logRepo.info( + LogEventType.NOTIFICATION, + "Phantom injected raw heuristic packet", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + "conKgAvg=${heuristic.concentric.kgAvg}; eccKgAvg=${heuristic.eccentric.kgAvg}; hex=${data.joinToString(" ") { it.toVitruvianHex() }}", + ) + } + } + + fun replaceConfig(config: PhantomBleConfig) { + lifecycleLock.withLock { + if (terminal.value || lifecycleCleanupInProgress || connectionAttemptReservationActive) { + return@withLock + } + val expectedConnectionGeneration = connectionAttemptGeneration.value + _config.value = config + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + return@withLock + } + logRepo.info( + LogEventType.NOTIFICATION, + "Phantom config updated", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + "loadScale=${config.loadScale}; velocityScale=${config.velocityScale}; positionScale=${config.positionScale}; repDelayMs=${config.repDelayMs}; autoCompleteFixedRepSets=${config.autoCompleteFixedRepSets}", + ) + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + return@withLock + } + if (_connectionState.value is ConnectionState.Connected) { + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + return@withLock + } + val activeWorkout = workoutParams != null || + activePollingConnectionGeneration == expectedConnectionGeneration + startMetrics( + activeWorkout = activeWorkout, + expectedConnectionGeneration = expectedConnectionGeneration, + ) + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + return@withLock + } + if (!startHeuristicGeneration( + activeWorkout = activeWorkout, + expectedConnectionGeneration = expectedConnectionGeneration, + ) + ) { + return@withLock + } + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + return@withLock + } + workoutParams + ?.takeIf { repJob?.isActive == true && !repSimulationCompleted } + ?.let { + startRepSimulation( + params = it, + expectedConnectionGeneration = expectedConnectionGeneration, + ) + } + } + } + } + + private fun beginConnectionAttempt(reservedState: ConnectionState): Long? = lifecycleLock.withLock { + if (terminal.value || lifecycleCleanupInProgress || connectionAttemptReservationActive) { + null + } else { + connectionAttemptReservationActive = true + try { + val attemptGeneration = connectionAttemptGeneration.incrementAndGet() + activePollingConnectionGeneration = null + discoControlGeneration += 1 + _discoModeActive.value = false + if (reservedState == ConnectionState.Scanning) { + _diagnostics.value = null + _heuristicData.value = null + _scannedDevices.value = emptyList() + } + if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { + null + } else { + _connectionState.value = reservedState + if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { + null + } else { + attemptGeneration + } + } + } finally { + connectionAttemptReservationActive = false + } + } + } + + private fun invalidateCancelledConnectionAttempt( + attemptGeneration: Long, + expectedState: ConnectionState? = null, + ) { + lifecycleLock.withLock { + val currentState = _connectionState.value + val matchesAttempt = + !terminal.value && + !lifecycleCleanupInProgress && + connectionAttemptGeneration.value == attemptGeneration && + ( + (expectedState == null && + (currentState == ConnectionState.Scanning || + currentState == ConnectionState.Connecting || + currentState is ConnectionState.Connected)) || + (expectedState != null && + (currentState == expectedState || + (expectedState == ConnectionState.Connecting && currentState is ConnectionState.Connected))) + ) + if (matchesAttempt) { + lifecycleCleanupInProgress = true + try { + teardownConnection() + } finally { + lifecycleCleanupInProgress = false + } + } + } + } + + private inline fun publishConnectionState( + attemptGeneration: Long, + state: ConnectionState, + onPublished: () -> Boolean, + ): Boolean = lifecycleLock.withLock { + if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { + false + } else { + _connectionState.value = state + if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { + false + } else if (!onPublished()) { + false + } else { + !terminal.value && connectionAttemptGeneration.value == attemptGeneration + } + } + } + + private fun publishScannedDevices(attemptGeneration: Long, devices: List): Boolean = lifecycleLock.withLock { + if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { + false + } else { + _scannedDevices.value = devices + if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { + return@withLock false + } + logRepo.info( + LogEventType.DEVICE_FOUND, + "Found phantom Vitruvian device", + deviceName = device.name, + deviceAddress = device.address, + details = "RSSI ${device.rssi}; no Bluetooth hardware used", + ) + !terminal.value && connectionAttemptGeneration.value == attemptGeneration + } + } + + private fun completeConnection(attemptGeneration: Long, device: ScannedDevice): Boolean = lifecycleLock.withLock { + if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { + return@withLock false + } + val handleStateControlGenerationBeforePublication = handleStateControlGeneration + val handleDetectionControlGenerationBeforePublication = handleDetectionControlGeneration + _connectionState.value = ConnectionState.Connected(device.name, device.address) + if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { + return@withLock false + } + if (handleDetectionControlGeneration == handleDetectionControlGenerationBeforePublication) { + _handleDetection.value = HandleDetection(leftDetected = true, rightDetected = true) + } + if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { + return@withLock false + } + val workoutOwnsConnection = + activePollingOwnsConnection(attemptGeneration) || + (workoutConnectionGeneration == attemptGeneration && + (_handleState.value == HandleState.Grabbed || workoutParams != null)) + if (!workoutOwnsConnection && handleStateControlGeneration == handleStateControlGenerationBeforePublication) { + _handleState.value = HandleState.Released + } + if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { + return@withLock false + } + logRepo.info(LogEventType.SERVICE_DISCOVERED, "Phantom service map ready", device.name, device.address) + if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { + return@withLock false + } + logRepo.info(LogEventType.CONNECT_SUCCESS, "Connected to phantom Vitruvian", device.name, device.address) + if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { + return@withLock false + } + if (!startDiagnostics(expectedConnectionGeneration = attemptGeneration) && + !currentConnectedProducerOwnsConnection(attemptGeneration, diagnosticJob) + ) { + return@withLock false + } + if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { + return@withLock false + } + val activeWorkoutParams = workoutParams + val completionOwnsWorkout = + activePollingOwnsConnection(attemptGeneration) || + (workoutConnectionGeneration == attemptGeneration && + (_handleState.value == HandleState.Grabbed || activeWorkoutParams != null)) + if (!completionOwnsWorkout) { + if (activeWorkoutParams != null) { + workoutParams = null + workoutConnectionGeneration = null + } + startMetrics( + activeWorkout = false, + expectedConnectionGeneration = attemptGeneration, + ) + if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { + return@withLock false + } + if (workoutParams == null && + !startHeuristicGeneration( + activeWorkout = false, + expectedConnectionGeneration = attemptGeneration, + ) && + !currentConnectedProducerOwnsConnection(attemptGeneration, heuristicJob) && + !activePollingOwnsConnection(attemptGeneration) + ) { + if (terminal.value || connectionAttemptGeneration.value != attemptGeneration || workoutParams == null) { + return@withLock false + } + } + } else if (activeWorkoutParams != null) { + if (metricsJob?.isActive != true) { + startMetrics( + activeWorkout = true, + expectedConnectionGeneration = attemptGeneration, + ) + } + if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { + return@withLock false + } + if (heuristicJob?.isActive != true && + !startHeuristicGeneration( + activeWorkout = true, + expectedConnectionGeneration = attemptGeneration, + ) && + !currentConnectedProducerOwnsConnection(attemptGeneration, heuristicJob) && + !activePollingOwnsConnection(attemptGeneration) + ) { + return@withLock false + } + if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { + return@withLock false + } + if (!repSimulationCompleted && repJob?.isActive != true) { + startRepSimulation( + params = activeWorkoutParams, + expectedConnectionGeneration = attemptGeneration, + ) + } + } + if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { + return@withLock false + } + startHeartbeat(attemptGeneration) + if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { + return@withLock false + } + true + } + + private fun workoutHandoffIsCurrent( + params: WorkoutParameters, + expectedConnectionGeneration: Long, + ): Boolean = + !terminal.value && + !lifecycleCleanupInProgress && + !connectionAttemptReservationActive && + _connectionState.value is ConnectionState.Connected && + connectionAttemptGeneration.value == expectedConnectionGeneration && + workoutConnectionGeneration == expectedConnectionGeneration && + workoutParams === params && + _handleState.value == HandleState.Grabbed + + private fun rollbackWorkoutHandoff( + params: WorkoutParameters, + expectedConnectionGeneration: Long, + ) { + if (workoutParams !== params || workoutConnectionGeneration != expectedConnectionGeneration) { + return + } + metricsGeneration += 1 + heuristicGeneration += 1 + repGeneration += 1 + metricsJob?.cancel() + heuristicJob?.cancel() + repJob?.cancel() + metricsJob = null + heuristicJob = null + repJob = null + workoutParams = null + workoutConnectionGeneration = null + if (_handleState.value == HandleState.Grabbed) { + _handleState.value = HandleState.Released + } + } + + private fun currentConnectedProducerOwnsConnection( + expectedConnectionGeneration: Long, + producerJob: Job?, + ): Boolean = + !terminal.value && + !lifecycleCleanupInProgress && + !connectionAttemptReservationActive && + connectionAttemptGeneration.value == expectedConnectionGeneration && + _connectionState.value is ConnectionState.Connected && + producerJob?.isActive == true + + private fun activePollingOwnsConnection(expectedConnectionGeneration: Long): Boolean = + !terminal.value && + !lifecycleCleanupInProgress && + !connectionAttemptReservationActive && + connectionAttemptGeneration.value == expectedConnectionGeneration && + _connectionState.value is ConnectionState.Connected && + activePollingConnectionGeneration == expectedConnectionGeneration + + private fun teardownConnection(markTerminal: Boolean = false) { + lifecycleLock.withLock { + if (markTerminal) { + terminal.value = true + } + val cleanupGeneration = connectionAttemptGeneration.incrementAndGet() + stopJobs() + if (connectionAttemptGeneration.value != cleanupGeneration || (!markTerminal && terminal.value)) { + return@withLock + } + activePollingConnectionGeneration = null + workoutParams = null + workoutConnectionGeneration = null + currentWorkoutProgram = null + _handleDetection.value = HandleDetection() + if (connectionAttemptGeneration.value != cleanupGeneration || (!markTerminal && terminal.value)) { + return@withLock + } + _handleState.value = HandleState.WaitingForRest + if (connectionAttemptGeneration.value != cleanupGeneration || (!markTerminal && terminal.value)) { + return@withLock + } + _diagnostics.value = null + if (connectionAttemptGeneration.value != cleanupGeneration || (!markTerminal && terminal.value)) { + return@withLock + } + _heuristicData.value = null + if (connectionAttemptGeneration.value != cleanupGeneration || (!markTerminal && terminal.value)) { + return@withLock + } + _scannedDevices.value = emptyList() + if (connectionAttemptGeneration.value != cleanupGeneration || (!markTerminal && terminal.value)) { + return@withLock + } + _discoModeActive.value = false + if (connectionAttemptGeneration.value != cleanupGeneration || (!markTerminal && terminal.value)) { + return@withLock + } + _connectionState.value = ConnectionState.Disconnected + } + } + + private inline fun publishIfConnected( + expectedConnectionGeneration: Long? = null, + expectedMetricsGeneration: Long? = null, + expectedHeuristicGeneration: Long? = null, + expectedRepGeneration: Long? = null, + expectedDiagnosticGeneration: Long? = null, + expectedHeartbeatGeneration: Long? = null, + publish: () -> Unit, + ): Boolean = lifecycleLock.withLock { + if ( + terminal.value || + lifecycleCleanupInProgress || + connectionAttemptReservationActive || + _connectionState.value !is ConnectionState.Connected || + (expectedConnectionGeneration != null && connectionAttemptGeneration.value != expectedConnectionGeneration) || + (expectedMetricsGeneration != null && metricsGeneration != expectedMetricsGeneration) || + (expectedHeuristicGeneration != null && heuristicGeneration != expectedHeuristicGeneration) || + (expectedRepGeneration != null && repGeneration != expectedRepGeneration) || + (expectedDiagnosticGeneration != null && diagnosticGeneration != expectedDiagnosticGeneration) || + (expectedHeartbeatGeneration != null && heartbeatGeneration != expectedHeartbeatGeneration) + ) { + false + } else { + publish() + !terminal.value && + !lifecycleCleanupInProgress && + !connectionAttemptReservationActive && + _connectionState.value is ConnectionState.Connected && + (expectedConnectionGeneration == null || connectionAttemptGeneration.value == expectedConnectionGeneration) && + (expectedMetricsGeneration == null || metricsGeneration == expectedMetricsGeneration) && + (expectedHeuristicGeneration == null || heuristicGeneration == expectedHeuristicGeneration) && + (expectedRepGeneration == null || repGeneration == expectedRepGeneration) && + (expectedDiagnosticGeneration == null || diagnosticGeneration == expectedDiagnosticGeneration) && + (expectedHeartbeatGeneration == null || heartbeatGeneration == expectedHeartbeatGeneration) + } + } + + private fun startMetrics( + activeWorkout: Boolean, + expectedConnectionGeneration: Long? = null, + ) { + lifecycleLock.withLock { + val lifecycleGeneration = expectedConnectionGeneration ?: connectionAttemptGeneration.value + if (terminal.value || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != lifecycleGeneration + ) { + return@withLock + } + val workoutWeightPerCableKg = workoutParams?.weightPerCableKg + metricsGeneration += 1 + val expectedGeneration = metricsGeneration + metricsJob?.cancel() + metricsJob = scope.launch { + var sample = 0 + while (isActive && connectionState.value is ConnectionState.Connected) { + val now = Clock.System.now().toEpochMilliseconds() + val phase = (sample % 40) / 40.0 + val wave = sin(phase * 2.0 * PI).toFloat() + val config = _config.value + val configuredLoad = workoutWeightPerCableKg ?: 7.5f + val load = (if (activeWorkout) configuredLoad.coerceAtLeast(2f) else 1.5f) * config.loadScale + if (!publishIfConnected( + expectedConnectionGeneration = lifecycleGeneration, + expectedMetricsGeneration = expectedGeneration, + ) { + val metric = WorkoutMetric( + timestamp = now, + loadA = load + wave.coerceAtLeast(0f) * config.loadScale, + loadB = load + (-wave).coerceAtLeast(0f) * config.loadScale, + positionA = wave * 650f * config.positionScale, + positionB = wave * 640f * config.positionScale, + ticks = ticks++, + velocityA = wave * 250.0 * config.velocityScale, + velocityB = wave * 245.0 * config.velocityScale, + status = 0, + ) + _metricsFlow.tryEmit(metric) + if (!terminal.value && + connectionAttemptGeneration.value == lifecycleGeneration && + metricsGeneration == expectedGeneration + ) { + logRepo.debug( + LogEventType.NOTIFICATION, + "Phantom monitor metric", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + "ticks=${metric.ticks}; load=${metric.totalLoad}; posA=${metric.positionA}", + ) + } + }) { + break + } + sample++ + delay(if (activeWorkout) 250 else 750) + } + } + } + } + + private fun startHeuristicGeneration( + activeWorkout: Boolean, + expectedConnectionGeneration: Long? = null, + ): Boolean = lifecycleLock.withLock { + if (expectedConnectionGeneration != null && + (terminal.value || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration) + ) { + return@withLock false + } + val workoutWeightPerCableKg = workoutParams?.weightPerCableKg + heuristicGeneration += 1 + val expectedGeneration = heuristicGeneration + heuristicJob?.cancel() + if (!publishIfConnected( + expectedConnectionGeneration = expectedConnectionGeneration, + expectedHeuristicGeneration = expectedGeneration, + ) { + val config = _config.value + val configuredLoad = workoutWeightPerCableKg ?: 7.5f + val load = (if (activeWorkout) configuredLoad.coerceAtLeast(2f) else 1.5f) * config.loadScale + _heuristicData.value = HeuristicStatistics( + concentric = HeuristicPhaseStatistics(load, load + 1.5f, 0.42f, 0.70f, 85f, 130f), + eccentric = HeuristicPhaseStatistics(load * 0.9f, load + 1f, 0.38f, 0.62f, 72f, 110f), + timestamp = Clock.System.now().toEpochMilliseconds(), + ) + if (!terminal.value && + connectionAttemptGeneration.value == expectedConnectionGeneration && + heuristicGeneration == expectedGeneration + ) { + logRepo.debug( + LogEventType.NOTIFICATION, + "Phantom heuristic update", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + ) + } + }) { + return@withLock false + } + if (expectedConnectionGeneration != null && + (terminal.value || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration) + ) { + return@withLock false + } + heuristicJob = scope.launch { + while (isActive && connectionState.value is ConnectionState.Connected) { + delay(if (activeWorkout) 250 else 750) + val config = _config.value + val configuredLoad = workoutWeightPerCableKg ?: 7.5f + val load = (if (activeWorkout) configuredLoad.coerceAtLeast(2f) else 1.5f) * config.loadScale + if (!publishIfConnected( + expectedConnectionGeneration = expectedConnectionGeneration, + expectedHeuristicGeneration = expectedGeneration, + ) { + _heuristicData.value = HeuristicStatistics( + concentric = HeuristicPhaseStatistics(load, load + 1.5f, 0.42f, 0.70f, 85f, 130f), + eccentric = HeuristicPhaseStatistics(load * 0.9f, load + 1f, 0.38f, 0.62f, 72f, 110f), + timestamp = Clock.System.now().toEpochMilliseconds(), + ) + if (!terminal.value && + connectionAttemptGeneration.value == expectedConnectionGeneration && + heuristicGeneration == expectedGeneration + ) { + logRepo.debug( + LogEventType.NOTIFICATION, + "Phantom heuristic update", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + ) + } + }) { + break + } + } + } + true + } + + private fun monitorProcessorFor(expectedConnectionGeneration: Long): MonitorDataProcessor { + if (monitorProcessorConnectionGeneration != expectedConnectionGeneration) { + monitorProcessorConnectionGeneration = expectedConnectionGeneration + monitorProcessor = MonitorDataProcessor( + onDeloadOccurred = { + lifecycleLock.withLock { + if (!terminal.value && + !lifecycleCleanupInProgress && + !connectionAttemptReservationActive && + connectionAttemptGeneration.value == expectedConnectionGeneration + ) { + _deloadOccurredEvents.tryEmit(Unit) + } + } + }, + onRomViolation = { violation -> + lifecycleLock.withLock { + if (!terminal.value && + !lifecycleCleanupInProgress && + !connectionAttemptReservationActive && + connectionAttemptGeneration.value == expectedConnectionGeneration + ) { + logRepo.warning( + LogEventType.NOTIFICATION, + "Phantom raw monitor packet reported ROM violation", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + violation.name, + ) + } + } + }, + ) + } + return requireNotNull(monitorProcessor) + } + + private fun startRepSimulation( + params: WorkoutParameters, + expectedConnectionGeneration: Long, + ) { + repSimulationCompleted = false + repGeneration += 1 + val expectedGeneration = repGeneration + repJob?.cancel() + repJob = scope.launch { + var rep = 0 + val target = params.reps.coerceAtLeast(1) + while (isActive && connectionState.value is ConnectionState.Connected) { + val config = _config.value + delay(config.repDelayMs) + rep += 1 + val timestamp = Clock.System.now().toEpochMilliseconds() + val rawData = ByteArray(24).also { bytes -> + bytes[0] = rep.toByte() + bytes[4] = rep.toByte() + bytes[18] = params.warmupReps.toByte() + bytes[22] = target.toByte() + } + if (!publishIfConnected( + expectedConnectionGeneration = expectedConnectionGeneration, + expectedRepGeneration = expectedGeneration, + ) { + if (rep >= target && !params.isAMRAP && config.autoCompleteFixedRepSets) { + repSimulationCompleted = true + } + _repEvents.tryEmit( + RepNotification( + topCounter = rep, + completeCounter = rep, + repsRomCount = params.warmupReps.coerceAtMost(rep), + repsRomTotal = params.warmupReps, + repsSetCount = rep.coerceAtMost(target), + repsSetTotal = target, + rangeTop = 650f, + rangeBottom = -650f, + rawData = rawData, + timestamp = timestamp, + ), + ) + if (!terminal.value && + connectionAttemptGeneration.value == expectedConnectionGeneration && + repGeneration == expectedGeneration + ) { + logRepo.info( + LogEventType.REP_RECEIVED, + "Phantom rep notification", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + "rep=$rep/$target; timestamp=$timestamp", + ) + if (!terminal.value && + connectionAttemptGeneration.value == expectedConnectionGeneration && + repGeneration == expectedGeneration && + rep >= target && !params.isAMRAP && config.autoCompleteFixedRepSets + ) { + logRepo.info(LogEventType.COMMAND_RESPONSE, "Phantom target reps reached", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) + } + } + }) { + break + } + if (rep >= target && !params.isAMRAP && config.autoCompleteFixedRepSets) { + break + } + } + } + } + + private fun startDiagnostics(expectedConnectionGeneration: Long): Boolean { + if (terminal.value || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration + ) { + return false + } + diagnosticGeneration += 1 + val expectedGeneration = diagnosticGeneration + diagnosticJob?.cancel() + val connectedAt = Clock.System.now().toEpochMilliseconds() + if (!publishIfConnected( + expectedConnectionGeneration = expectedConnectionGeneration, + expectedDiagnosticGeneration = expectedGeneration, + ) { + val now = Clock.System.now().toEpochMilliseconds() + _diagnostics.value = DiagnosticPacket( + runtimeSeconds = (now - connectedAt) / 1000, + faultWords = listOf(0, 0, 0, 0), + temperatures = listOf(34, 35, 34, 35, 36, 36, 35, 34), + hasFaults = false, + receivedAtMillis = now, + ) + if (!terminal.value && + connectionAttemptGeneration.value == expectedConnectionGeneration && + diagnosticGeneration == expectedGeneration + ) { + logRepo.debug(LogEventType.DIAGNOSTIC, "Phantom diagnostic heartbeat", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) + } + }) { + return false + } + diagnosticJob = scope.launch { + while (isActive && connectionState.value is ConnectionState.Connected) { + delay(2_000) + val now = Clock.System.now().toEpochMilliseconds() + if (!publishIfConnected( + expectedConnectionGeneration = expectedConnectionGeneration, + expectedDiagnosticGeneration = expectedGeneration, + ) { + _diagnostics.value = DiagnosticPacket( + runtimeSeconds = (now - connectedAt) / 1000, + faultWords = listOf(0, 0, 0, 0), + temperatures = listOf(34, 35, 34, 35, 36, 36, 35, 34), + hasFaults = false, + receivedAtMillis = now, + ) + if (!terminal.value && + connectionAttemptGeneration.value == expectedConnectionGeneration && + diagnosticGeneration == expectedGeneration + ) { + logRepo.debug(LogEventType.DIAGNOSTIC, "Phantom diagnostic heartbeat", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) + } + }) { + break + } + } + } + return true + } + + private fun startHeartbeat(expectedConnectionGeneration: Long) { + if (terminal.value || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration + ) { + return + } + heartbeatGeneration += 1 + val expectedGeneration = heartbeatGeneration + heartbeatJob?.cancel() + heartbeatJob = scope.launch { + while (isActive && connectionState.value is ConnectionState.Connected) { + if (!publishIfConnected( + expectedConnectionGeneration = expectedConnectionGeneration, + expectedHeartbeatGeneration = expectedGeneration, + ) { + logRepo.info(LogEventType.HEARTBEAT, "Phantom heartbeat", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) + }) { + break + } + delay(2_000) + } + } + } + + private fun stopJobs() { + metricsGeneration += 1 + heuristicGeneration += 1 + repGeneration += 1 + diagnosticGeneration += 1 + heartbeatGeneration += 1 + metricsJob?.cancel() + heuristicJob?.cancel() + repJob?.cancel() + diagnosticJob?.cancel() + heartbeatJob?.cancel() + metricsJob = null + heuristicJob = null + repJob = null + diagnosticJob = null + heartbeatJob = null + } + + companion object { + private const val REGULAR_PROGRAM_OPCODE = 0x04 + private const val ECHO_PROGRAM_OPCODE = 0x4E + private const val REGULAR_PROGRAM_PACKET_SIZE = 96 + private const val ECHO_PROGRAM_PACKET_SIZE = 32 + private const val UNLIMITED_REPS = 0xFF + const val PHANTOM_DEVICE_NAME = "Vee_PhantomSimulator" + const val PHANTOM_DEVICE_ADDRESS = "PH:AN:TO:MS:BX:01" + } +} diff --git a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt new file mode 100644 index 000000000..4905f55a1 --- /dev/null +++ b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt @@ -0,0 +1,3656 @@ +package com.devil.phoenixproject.data.repository + +import app.cash.turbine.test +import com.devil.phoenixproject.domain.model.ConnectionState +import com.devil.phoenixproject.domain.model.EchoLevel +import com.devil.phoenixproject.domain.model.ProgramMode +import com.devil.phoenixproject.domain.model.WorkoutParameters +import com.devil.phoenixproject.util.BlePacketFactory +import kotlin.coroutines.cancellation.CancellationException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.test.runTest + +class PhantomBleRepositoryTest { + + @Test + fun `config defaults to 750 millisecond rep delay`() { + assertEquals(750L, PhantomBleConfig().repDelayMs) + } + + @Test + fun `config rejects non-positive load scale`() { + assertFailsWith { + PhantomBleConfig(loadScale = 0f) + } + assertFailsWith { + PhantomBleConfig(loadScale = -1f) + } + } + + @Test + fun `config rejects non-positive echo load`() { + assertEquals(20f, PhantomBleConfig().defaultEchoLoadKg) + assertFailsWith { + PhantomBleConfig(defaultEchoLoadKg = 0f) + } + assertFailsWith { + PhantomBleConfig(defaultEchoLoadKg = -1f) + } + } + + @Test + fun `regular factory packet records finite phantom workout program`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val params = WorkoutParameters( + programMode = ProgramMode.OldSchool, + reps = 7, + warmupReps = 3, + weightPerCableKg = 12.5f, + ) + + try { + val command = BlePacketFactory.createProgramParams(params) + assertEquals(96, command.size) + assertTrue(repository.sendWorkoutCommand(command).isSuccess) + assertEquals( + PhantomWorkoutProgram( + warmupReps = 3, + workingReps = 7, + weightPerCableKg = 12.5f, + ), + repository.currentProgram, + ) + assertTrue(logRepo.logs.value.any { log -> + log.eventType == LogEventType.COMMAND_SENT && + log.message == "Phantom parsed workout command" && + log.details == "warmupReps=3; workingReps=7; weightPerCableKg=12.5" + }) + } finally { + repository.shutdown() + } + } + + @Test + fun `regular production packet requires exact four-byte opcode`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + val params = WorkoutParameters( + programMode = ProgramMode.OldSchool, + reps = 7, + warmupReps = 3, + weightPerCableKg = 12.5f, + ) + + try { + val command = BlePacketFactory.createProgramParams(params).also { packet -> + packet[1] = 0x01 + } + assertEquals(96, command.size) + assertEquals(0x04.toByte(), command[0]) + assertEquals(0x01.toByte(), command[1]) + assertTrue(repository.sendWorkoutCommand(command).isSuccess) + assertNull(repository.currentProgram) + } finally { + repository.shutdown() + } + } + + @Test + fun `regular factory packet records unlimited phantom workout program`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + val params = WorkoutParameters( + programMode = ProgramMode.OldSchool, + reps = 0, + warmupReps = 3, + weightPerCableKg = 8.25f, + isAMRAP = true, + ) + + try { + val command = BlePacketFactory.createProgramParams(params) + assertEquals(96, command.size) + assertTrue(repository.sendWorkoutCommand(command).isSuccess) + assertEquals( + PhantomWorkoutProgram( + warmupReps = 3, + workingReps = null, + weightPerCableKg = 8.25f, + ), + repository.currentProgram, + ) + } finally { + repository.shutdown() + } + } + + @Test + fun `echo factory packet records phantom workout program with default load`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + + try { + val command = BlePacketFactory.createEchoControl( + level = EchoLevel.HARDER, + warmupReps = 2, + targetReps = 5, + ) + assertEquals(32, command.size) + assertTrue(repository.sendWorkoutCommand(command).isSuccess) + assertEquals( + PhantomWorkoutProgram( + warmupReps = 2, + workingReps = 5, + weightPerCableKg = 20f, + ), + repository.currentProgram, + ) + } finally { + repository.shutdown() + } + } + + @Test + fun `echo factory packet records unlimited phantom workout program`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + + try { + val command = BlePacketFactory.createEchoControl( + level = EchoLevel.HARDER, + warmupReps = 2, + isAMRAP = true, + ) + assertTrue(repository.sendWorkoutCommand(command).isSuccess) + assertEquals( + PhantomWorkoutProgram( + warmupReps = 2, + workingReps = null, + weightPerCableKg = 20f, + ), + repository.currentProgram, + ) + } finally { + repository.shutdown() + } + } + + @Test + fun `unrelated workout command remains accepted`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + + try { + assertTrue(repository.sendWorkoutCommand(byteArrayOf(0x01)).isSuccess) + assertNull(repository.currentProgram) + } finally { + repository.shutdown() + } + } + + @Test + fun `config rejects rep delays below 100 milliseconds`() { + assertFailsWith { + PhantomBleConfig(repDelayMs = 99L) + } + assertFailsWith { + PhantomBleConfig(repDelayMs = -1L) + } + } + + @Test + fun `scanAndConnect publishes simulator device and connected state`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + assertEquals(1, repository.scannedDevices.value.size) + assertEquals("Vee_PhantomSimulator", repository.scannedDevices.value.single().name) + assertTrue(repository.connectionState.value is ConnectionState.Connected) + } finally { + repository.shutdown() + } + } + + @Test + fun `disconnected workout start does not claim handle ownership`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + + try { + assertTrue(repository.startWorkout(workoutParameters()).isFailure) + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + assertEquals(HandleState.WaitingForRest, repository.handleState.value) + } finally { + repository.shutdown() + } + } + + @Test + fun `cancelConnection invalidates an in-flight connect`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + val connecting = async(Dispatchers.Default) { + repository.connect(ScannedDevice("race", "race-address")) + } + + try { + repository.connectionState.first { it == ConnectionState.Connecting } + repository.cancelConnection() + + val result = connecting.await() + + assertTrue(result.isFailure) + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + } finally { + repository.shutdown() + } + } + + @Test + fun `caller cancellation invalidates an in-flight scan`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + val scanning = async(Dispatchers.Default) { repository.startScanning() } + + try { + repository.connectionState.first { it == ConnectionState.Scanning } + scanning.cancel() + + assertFailsWith { scanning.await() } + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + assertTrue(repository.scannedDevices.value.isEmpty()) + } finally { + repository.shutdown() + } + } + + @Test + fun `caller cancellation invalidates an in-flight connect`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + val connecting = async(Dispatchers.Default) { + repository.connect(ScannedDevice("race", "race-address")) + } + + try { + repository.connectionState.first { it == ConnectionState.Connecting } + connecting.cancel() + + assertFailsWith { connecting.await() } + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + assertTrue(repository.scannedDevices.value.isEmpty()) + } finally { + repository.shutdown() + } + } + + @Test + fun `caller cancellation from final scanned-device publication invalidates scan`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + lateinit var scanning: Deferred> + val cancelOnDevices = async(Dispatchers.Unconfined) { + repository.scannedDevices.first { devices -> + if (devices.isNotEmpty()) { + scanning.cancel() + true + } else { + false + } + } + } + + try { + scanning = async(Dispatchers.Unconfined) { repository.startScanning() } + cancelOnDevices.await() + + assertFailsWith { scanning.await() } + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + assertTrue(repository.scannedDevices.value.isEmpty()) + } finally { + repository.shutdown() + } + } + + @Test + fun `caller cancellation from final connected publication invalidates connect`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + lateinit var connecting: Deferred> + val cancelOnConnected = async(Dispatchers.Unconfined) { + repository.connectionState.first { state -> + if (state is ConnectionState.Connected) { + connecting.cancel() + true + } else { + false + } + } + } + + try { + connecting = async(Dispatchers.Unconfined) { + repository.connect(ScannedDevice("collector", "collector-address")) + } + cancelOnConnected.await() + + assertFailsWith { connecting.await() } + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + assertTrue(repository.scannedDevices.value.isEmpty()) + assertNull(repository.diagnostics.value) + assertNull(repository.heuristicData.value) + } finally { + repository.shutdown() + } + } + + @Test + fun `stopScanning invalidates a scanning attempt and resets state`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + val scanning = async(Dispatchers.Default) { repository.startScanning() } + + try { + repository.connectionState.first { it == ConnectionState.Scanning } + repository.stopScanning() + + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + assertTrue(scanning.await().isFailure) + } finally { + repository.shutdown() + } + } + + @Test + fun `stopScanning invalidates a connecting attempt and resets state`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + val connecting = async(Dispatchers.Default) { + repository.connect(ScannedDevice("race", "race-address")) + } + + try { + repository.connectionState.first { it == ConnectionState.Connecting } + repository.stopScanning() + + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + assertTrue(connecting.await().isFailure) + } finally { + repository.shutdown() + } + } + + @Test + fun `starting a new scan clears devices from the previous scan`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + assertTrue(repository.scannedDevices.value.isNotEmpty()) + + val scanning = async(Dispatchers.Default) { repository.startScanning() } + withContext(Dispatchers.Default) { + withTimeout(1_000L) { + repository.scannedDevices.first { it.isEmpty() } + } + } + + assertEquals(ConnectionState.Scanning, repository.connectionState.value) + assertTrue(scanning.await().isSuccess) + } finally { + repository.shutdown() + } + } + + @Test + fun `stopScanning is a no-op while initial connection producers publish`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val stopScanningOnDiagnostics = async(Dispatchers.Unconfined) { + repository.diagnostics.first { diagnostic -> + if (diagnostic != null) { + repository.stopScanning() + true + } else { + false + } + } + } + + try { + val result = repository.connect(ScannedDevice("collector", "collector-address")) + + stopScanningOnDiagnostics.await() + assertTrue(result.isSuccess) + assertTrue(repository.connectionState.value is ConnectionState.Connected) + assertTrue(repository.diagnostics.value != null) + assertTrue(repository.heuristicData.value != null) + assertTrue(logRepo.getLogsByEventType(LogEventType.CONNECT_SUCCESS).isNotEmpty()) + assertTrue(logRepo.getLogsByEventType(LogEventType.SCAN_STOP).isEmpty()) + } finally { + repository.shutdown() + } + } + + @Test + fun `connected repository initializes diagnostics and heuristic state before workout cancellation`() = runTest { + val repository = PhantomBleRepository( + ConnectionLogRepository(), + PhantomBleConfig(repDelayMs = 100L), + ) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + assertTrue(repository.diagnostics.value != null) + assertTrue(repository.heuristicData.value != null) + + repository.repEvents.test { + assertTrue(repository.startWorkout(workoutParameters()).isSuccess) + repository.cancelConnection() + + assertTrue(repository.connectionState.value is ConnectionState.Connected) + assertTrue(repository.scannedDevices.value.isNotEmpty()) + assertTrue(repository.handleDetection.value.leftDetected) + assertEquals(HandleState.Grabbed, repository.handleState.value) + assertTrue(repository.diagnostics.value != null) + assertTrue(repository.heuristicData.value != null) + awaitItem() + cancelAndIgnoreRemainingEvents() + } + } finally { + repository.shutdown() + } + } + + @Test + fun `shutdown wins over an in-flight scan`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val scanning = async(Dispatchers.Default) { repository.startScanning() } + + try { + repository.connectionState.first { it == ConnectionState.Scanning } + repository.shutdown() + val logsAfterShutdown = logRepo.logs.value.size + + assertTrue(scanning.await().isFailure) + assertEquals(logsAfterShutdown, logRepo.logs.value.size) + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + assertTrue(repository.scannedDevices.value.isEmpty()) + assertNull(repository.heuristicData.value) + assertNull(repository.diagnostics.value) + assertFalse(repository.connectionState.value is ConnectionState.Connected) + } finally { + repository.shutdown() + } + } + + @Test + fun `shutdown after scan publication prevents post-terminal device-found log`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val shutdownOnPublication = async(Dispatchers.Default) { + repository.scannedDevices.first { it.isNotEmpty() } + repository.shutdown() + } + val scanning = async(Dispatchers.Default) { repository.startScanning() } + + try { + shutdownOnPublication.await() + val logsAfterShutdown = logRepo.logs.value.size + + assertTrue(scanning.await().isSuccess) + assertEquals(logsAfterShutdown, logRepo.logs.value.size) + } finally { + repository.shutdown() + } + } + + @Test + fun `shutdown wins over a racing connect`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val connecting = async(Dispatchers.Default) { + repository.connect(ScannedDevice("race", "race-address")) + } + + try { + repository.connectionState.first { it == ConnectionState.Connecting } + repository.shutdown() + val logsAfterShutdown = logRepo.logs.value.size + + assertTrue(connecting.await().isFailure) + withContext(Dispatchers.Default) { delay(350L) } + assertEquals(logsAfterShutdown, logRepo.logs.value.size) + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + assertTrue(repository.scannedDevices.value.isEmpty()) + assertNull(repository.heuristicData.value) + assertNull(repository.diagnostics.value) + assertFalse(repository.connectionState.value is ConnectionState.Connected) + } finally { + repository.shutdown() + } + } + + @Test + fun `shutdown from connected collector invalidates completing connection`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + val shutdownOnConnected = async(Dispatchers.Unconfined) { + repository.connectionState.first { state -> + if (state is ConnectionState.Connected) { + repository.shutdown() + true + } else { + false + } + } + } + + try { + val result = repository.connect(ScannedDevice("collector", "collector-address")) + + shutdownOnConnected.await() + + assertTrue(result.isFailure) + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + assertTrue(repository.scannedDevices.value.isEmpty()) + assertNull(repository.diagnostics.value) + assertNull(repository.heuristicData.value) + } finally { + repository.shutdown() + } + } + + @Test + fun `connected collector workout preserves grabbed handle and active producers`() = runTest { + val repository = PhantomBleRepository( + ConnectionLogRepository(), + PhantomBleConfig(repDelayMs = 100L), + ) + val startWorkoutOnConnected = async(Dispatchers.Unconfined) { + repository.connectionState.first { state -> + if (state is ConnectionState.Connected) { + assertTrue(repository.startWorkout(workoutParameters()).isSuccess) + true + } else { + false + } + } + } + + try { + assertTrue(repository.connect(ScannedDevice("collector", "collector-address")).isSuccess) + startWorkoutOnConnected.await() + + assertEquals(HandleState.Grabbed, repository.handleState.value) + + val metric = withContext(Dispatchers.Default) { + withTimeout(1_000L) { repository.metricsFlow.first() } + } + assertTrue(metric.loadA >= 10f, "connected handoff must preserve active workout metrics") + + val rep = withContext(Dispatchers.Default) { + withTimeout(1_000L) { repository.repEvents.first() } + } + assertTrue(rep.topCounter >= 1) + } finally { + repository.shutdown() + } + } + + @Test + fun `connected collector active polling owns connection completion producers`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + val startPollingOnConnected = async(Dispatchers.Unconfined) { + repository.connectionState.first { state -> + if (state is ConnectionState.Connected) { + repository.startActiveWorkoutPolling() + true + } else { + false + } + } + } + + try { + assertTrue(repository.connect(ScannedDevice("collector", "collector-address")).isSuccess) + startPollingOnConnected.await() + + assertEquals(HandleState.Grabbed, repository.handleState.value) + val metric = withContext(Dispatchers.Default) { + withTimeout(1_000L) { repository.metricsFlow.first() } + } + assertTrue(metric.loadA >= 2f, "completion must preserve reentrant active polling") + } finally { + repository.shutdown() + } + } + + @Test + fun `active polling rejects scanning and connecting states without claiming ownership`() = runTest { + val scanningRepository = PhantomBleRepository(ConnectionLogRepository()) + var handleStateWhenScanning: HandleState? = null + val startPollingOnScanning = async(Dispatchers.Unconfined) { + scanningRepository.connectionState.first { state -> + if (state == ConnectionState.Scanning) { + scanningRepository.startActiveWorkoutPolling() + handleStateWhenScanning = scanningRepository.handleState.value + true + } else { + false + } + } + } + + try { + val scanning = async(Dispatchers.Default) { scanningRepository.startScanning() } + startPollingOnScanning.await() + + assertEquals(HandleState.WaitingForRest, handleStateWhenScanning) + assertTrue(scanning.await().isSuccess) + assertEquals(HandleState.WaitingForRest, scanningRepository.handleState.value) + } finally { + scanningRepository.shutdown() + } + + val connectingRepository = PhantomBleRepository(ConnectionLogRepository()) + var handleStateWhenConnecting: HandleState? = null + val startPollingOnConnecting = async(Dispatchers.Unconfined) { + connectingRepository.connectionState.first { state -> + if (state == ConnectionState.Connecting) { + connectingRepository.startActiveWorkoutPolling() + handleStateWhenConnecting = connectingRepository.handleState.value + true + } else { + false + } + } + } + + try { + val connecting = async(Dispatchers.Default) { + connectingRepository.connect(ScannedDevice("precondition", "precondition-address")) + } + startPollingOnConnecting.await() + + assertEquals(HandleState.WaitingForRest, handleStateWhenConnecting) + assertTrue(connecting.await().isSuccess) + assertEquals(HandleState.Released, connectingRepository.handleState.value) + } finally { + connectingRepository.shutdown() + } + } + + @Test + fun `heuristic collector active polling preserves connection completion`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val startPollingOnHeuristic = async(Dispatchers.Unconfined) { + repository.heuristicData.first { heuristic -> + if (heuristic != null) { + repository.startActiveWorkoutPolling() + true + } else { + false + } + } + } + + try { + assertTrue(repository.connect(ScannedDevice("heuristic", "heuristic-address")).isSuccess) + startPollingOnHeuristic.await() + + assertTrue(repository.connectionState.value is ConnectionState.Connected) + assertEquals(HandleState.Grabbed, repository.handleState.value) + val metric = withContext(Dispatchers.Default) { + withTimeout(1_000L) { repository.metricsFlow.first() } + } + assertTrue(metric.loadA >= 2f, "heuristic handoff must preserve active workout metrics") + withContext(Dispatchers.Default) { + withTimeout(1_000L) { + logRepo.logs.first { logs -> + logs.any { log -> + log.eventType == LogEventType.HEARTBEAT && log.message == "Phantom heartbeat" + } + } + } + } + } finally { + repository.stopPolling() + repository.shutdown() + } + } + + @Test + fun `diagnostic producer replacement during connection preserves success`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val restartOnDiagnostic = async(Dispatchers.Unconfined) { + repository.diagnostics.first { diagnostic -> + if (diagnostic != null) { + repository.restartDiagnosticPolling() + true + } else { + false + } + } + } + + try { + val result = repository.connect(ScannedDevice("diagnostic", "diagnostic-address")) + + restartOnDiagnostic.await() + assertTrue(result.isSuccess) + assertTrue(repository.connectionState.value is ConnectionState.Connected) + withContext(Dispatchers.Default) { + withTimeout(1_000L) { + logRepo.logs.first { logs -> + logs.any { log -> + log.eventType == LogEventType.HEARTBEAT && log.message == "Phantom heartbeat" + } + } + } + } + } finally { + repository.shutdown() + } + } + + @Test + fun `heuristic producer replacement during connection preserves success`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val replacement = PhantomBleConfig(loadScale = 2f) + val replaceOnHeuristic = async(Dispatchers.Unconfined) { + repository.heuristicData.first { heuristic -> + if (heuristic != null) { + repository.replaceConfig(replacement) + true + } else { + false + } + } + } + + try { + val result = repository.connect(ScannedDevice("heuristic", "heuristic-address")) + + replaceOnHeuristic.await() + assertTrue(result.isSuccess) + assertTrue(repository.connectionState.value is ConnectionState.Connected) + withContext(Dispatchers.Default) { + withTimeout(1_000L) { + logRepo.logs.first { logs -> + logs.any { log -> + log.eventType == LogEventType.HEARTBEAT && log.message == "Phantom heartbeat" + } + } + } + } + } finally { + repository.shutdown() + } + } + + @Test + fun `handle detection collector workout preserves connected handoff`() = runTest { + val repository = PhantomBleRepository( + ConnectionLogRepository(), + PhantomBleConfig(repDelayMs = 100L), + ) + val startWorkoutOnHandleDetection = async(Dispatchers.Unconfined) { + repository.handleDetection.first { detection -> + if (detection.leftDetected) { + assertTrue(repository.startWorkout(workoutParameters()).isSuccess) + true + } else { + false + } + } + } + + try { + assertTrue(repository.connect(ScannedDevice("collector", "collector-address")).isSuccess) + startWorkoutOnHandleDetection.await() + + assertTrue(repository.connectionState.value is ConnectionState.Connected) + assertEquals(HandleState.Grabbed, repository.handleState.value) + val metric = withContext(Dispatchers.Default) { + withTimeout(1_000L) { repository.metricsFlow.first() } + } + assertTrue(metric.loadA >= 10f, "handle detection handoff must preserve active workout metrics") + } finally { + repository.shutdown() + } + } + + @Test + fun `handle state collector workout preserves grabbed handle and active producers`() = runTest { + val repository = PhantomBleRepository( + ConnectionLogRepository(), + PhantomBleConfig(repDelayMs = 100L), + ) + val startWorkoutOnHandleState = async(Dispatchers.Unconfined) { + repository.handleState.first { state -> + if (state == HandleState.Released) { + assertTrue(repository.startWorkout(workoutParameters()).isSuccess) + true + } else { + false + } + } + } + + try { + assertTrue(repository.connect(ScannedDevice("collector", "collector-address")).isSuccess) + startWorkoutOnHandleState.await() + + assertTrue(repository.connectionState.value is ConnectionState.Connected) + assertEquals(HandleState.Grabbed, repository.handleState.value) + val metric = withContext(Dispatchers.Default) { + withTimeout(1_000L) { repository.metricsFlow.first() } + } + assertTrue(metric.loadA >= 10f, "handle state handoff must preserve active workout metrics") + } finally { + repository.shutdown() + } + } + + @Test + fun `starting a scan clears stale devices before publishing scanning`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + assertTrue(repository.scannedDevices.value.isNotEmpty()) + val scanningWithEmptyDevices = async(Dispatchers.Unconfined) { + repository.connectionState.first { state -> + if (state == ConnectionState.Scanning) { + assertTrue(repository.scannedDevices.value.isEmpty()) + true + } else { + false + } + } + } + val scanning = async(Dispatchers.Default) { repository.startScanning() } + + scanningWithEmptyDevices.await() + assertTrue(scanning.await().isSuccess) + } finally { + repository.shutdown() + } + } + + @Test + fun `scan clear rejects reentrant workout before scanning publication`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + var stateWhenCleared: ConnectionState? = null + var startWorkoutResult: Result? = null + val startWorkoutOnClear = async(Dispatchers.Unconfined) { + repository.scannedDevices.drop(1).first { devices -> + if (devices.isEmpty()) { + stateWhenCleared = repository.connectionState.value + startWorkoutResult = repository.startWorkout(workoutParameters()) + true + } else { + false + } + } + } + + try { + assertTrue(repository.scanAndConnect().isSuccess) + + val scanning = async(Dispatchers.Default) { repository.startScanning() } + startWorkoutOnClear.await() + + assertTrue(stateWhenCleared is ConnectionState.Connected) + assertTrue(requireNotNull(startWorkoutResult).isFailure) + assertTrue(scanning.await().isSuccess) + assertEquals(ConnectionState.Scanning, repository.connectionState.value) + } finally { + repository.shutdown() + } + } + + @Test + fun `connection attempt reservation rejects reentrant producer controls before scanning publication`() = runTest { + val logRepo = ConnectionLogRepository() + val initialConfig = PhantomBleConfig(repDelayMs = 100L) + val replacement = PhantomBleConfig(loadScale = 2f, repDelayMs = 100L) + val repository = PhantomBleRepository(logRepo, initialConfig) + val controlsOnClear = async(Dispatchers.Unconfined) { + repository.scannedDevices.drop(1).first { devices -> + if (devices.isEmpty()) { + repository.restartMonitorPolling() + repository.startActiveWorkoutPolling() + repository.restartDiagnosticPolling() + repository.replaceConfig(replacement) + true + } else { + false + } + } + } + + try { + assertTrue(repository.scanAndConnect().isSuccess) + repository.stopPolling() + logRepo.clearAll() + + val scanning = async(Dispatchers.Default) { repository.startScanning() } + assertTrue(controlsOnClear.await().isEmpty()) + + assertTrue(scanning.await().isSuccess) + assertEquals(ConnectionState.Scanning, repository.connectionState.value) + assertEquals(HandleState.Released, repository.handleState.value) + assertEquals(initialConfig, repository.config.value) + assertTrue(logRepo.logs.value.none { log -> + log.message in setOf( + "Phantom monitor metric", + "Phantom heuristic update", + "Phantom diagnostic heartbeat", + "Phantom config updated", + ) + }) + } finally { + repository.shutdown() + } + } + + @Test + fun `reset handle preserves active connected polling`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val resetOnActiveMetric = async(Dispatchers.Unconfined) { + repository.metricsFlow.first { metric -> + if (metric.loadA >= 2f) { + repository.resetHandleState() + true + } else { + false + } + } + } + + try { + assertTrue(repository.scanAndConnect().isSuccess) + repository.startActiveWorkoutPolling() + resetOnActiveMetric.await() + logRepo.clearAll() + + assertTrue(repository.connectionState.value is ConnectionState.Connected) + assertEquals(HandleState.WaitingForRest, repository.handleState.value) + withContext(Dispatchers.Default) { + withTimeout(1_000L) { + logRepo.logs.first { logs -> + logs.any { log -> log.message == "Phantom monitor metric" } && + logs.any { log -> log.message == "Phantom heuristic update" } + } + } + } + } finally { + repository.shutdown() + } + } + + @Test + fun `heuristic invalidation rolls back workout producers`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo, PhantomBleConfig(repDelayMs = 100L)) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + logRepo.clearAll() + val resetOnWorkoutHeuristic = async(Dispatchers.Unconfined) { + repository.heuristicData.drop(1).first { + repository.resetHandleState() + true + } + } + + val result = repository.startWorkout(workoutParameters()) + + resetOnWorkoutHeuristic.await() + assertTrue(result.isFailure) + assertEquals(HandleState.WaitingForRest, repository.handleState.value) + val workoutProducerLogsAfterRollback = logRepo.logs.value.filter { log -> + log.message in setOf( + "Phantom monitor metric", + "Phantom heuristic update", + "Phantom rep notification", + ) + } + withContext(Dispatchers.Default) { delay(350L) } + assertEquals( + workoutProducerLogsAfterRollback, + logRepo.logs.value.filter { log -> + log.message in setOf( + "Phantom monitor metric", + "Phantom heuristic update", + "Phantom rep notification", + ) + }, + ) + } finally { + repository.shutdown() + } + } + + @Test + fun `inline heuristic config replacement preserves workout handoff`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository(), PhantomBleConfig(repDelayMs = 100L)) + val replacement = PhantomBleConfig(loadScale = 2f, repDelayMs = 100L) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + val replaceOnHeuristic = async(Dispatchers.Unconfined) { + repository.heuristicData.drop(1).first { heuristic -> + if (heuristic != null) { + repository.replaceConfig(replacement) + true + } else { + false + } + } + } + val result = repository.startWorkout(workoutParameters()) + + replaceOnHeuristic.await() + assertTrue(result.isSuccess) + assertEquals(replacement, repository.config.value) + assertEquals(HandleState.Grabbed, repository.handleState.value) + val metric = withContext(Dispatchers.Default) { + withTimeout(1_000L) { repository.metricsFlow.first { it.loadA >= 10f } } + } + assertTrue(metric.loadA >= 10f) + } finally { + repository.shutdown() + } + } + + @Test + fun `active polling remains active across monitor restart and config replacement`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + repository.startActiveWorkoutPolling() + withContext(Dispatchers.Default) { + withTimeout(1_000L) { repository.metricsFlow.first { it.loadA >= 2f } } + } + + val metricAfterRestart = async(Dispatchers.Unconfined) { + withTimeout(1_000L) { repository.metricsFlow.first { it.loadA >= 2f } } + } + repository.restartMonitorPolling() + metricAfterRestart.await() + + val metricAfterReplacement = async(Dispatchers.Unconfined) { + withTimeout(1_000L) { repository.metricsFlow.first { it.loadA >= 10f } } + } + repository.replaceConfig(PhantomBleConfig(loadScale = 2f)) + metricAfterReplacement.await() + } finally { + repository.shutdown() + } + } + + @Test + fun `beginning a connection attempt gates reentrant producer restarts`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val replacement = PhantomBleConfig(loadScale = 2f, repDelayMs = 100L) + val restartOnConnected = async(Dispatchers.Unconfined) { + repository.connectionState.first { state -> + if (state is ConnectionState.Connected) { + repository.restartMonitorPolling() + repository.restartDiagnosticPolling() + repository.replaceConfig(replacement) + true + } else { + false + } + } + } + + try { + assertTrue(repository.scanAndConnect().isSuccess) + restartOnConnected.await() + + val reconnecting = async(Dispatchers.Unconfined) { + repository.connect(ScannedDevice("reentrant", "reentrant-address")) + } + repository.connectionState.first { it == ConnectionState.Connecting } + logRepo.clearAll() + + withContext(Dispatchers.Default) { delay(100L) } + assertTrue(logRepo.getLogsByEventType(LogEventType.NOTIFICATION).none { it.message == "Phantom monitor metric" }) + assertTrue(logRepo.getLogsByEventType(LogEventType.DIAGNOSTIC).none { it.message == "Phantom diagnostic heartbeat" }) + assertTrue(reconnecting.await().isSuccess) + } finally { + repository.shutdown() + } + } + + @Test + fun `diagnostic collector workout owns connection completion producers`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val startWorkoutOnDiagnostic = async(Dispatchers.Unconfined) { + repository.diagnostics.first { diagnostic -> + if (diagnostic != null) { + assertTrue(repository.startWorkout(workoutParameters()).isSuccess) + true + } else { + false + } + } + } + + try { + val metric = async(Dispatchers.Unconfined) { repository.metricsFlow.first() } + val metricProducer = async(Dispatchers.Unconfined) { + logRepo.logs.first { logs -> logs.any { log -> log.message == "Phantom monitor metric" } } + } + assertTrue(repository.connect(ScannedDevice("diagnostic", "diagnostic-address")).isSuccess) + startWorkoutOnDiagnostic.await() + withContext(Dispatchers.Default) { + withTimeout(1_000L) { metricProducer.await() } + } + + val observedMetric = withContext(Dispatchers.Default) { + withTimeout(1_000L) { metric.await() } + } + assertTrue(observedMetric.loadA >= 2f, "completion must preserve the reentrant active workout producer") + metric.cancel() + } finally { + repository.shutdown() + } + } + + @Test + fun `shutdown from metric collector prevents post-terminal metric logging and state repopulation`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val shutdownOnMetric = async(Dispatchers.Unconfined) { + repository.metricsFlow.first { + repository.shutdown() + true + } + } + + try { + repository.scanAndConnect() + shutdownOnMetric.await() + val logsAfterShutdown = logRepo.logs.value.size + + withContext(Dispatchers.Default) { delay(350L) } + + assertEquals(logsAfterShutdown, logRepo.logs.value.size) + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + assertTrue(repository.scannedDevices.value.isEmpty()) + assertNull(repository.diagnostics.value) + assertNull(repository.heuristicData.value) + assertFalse(repository.handleDetection.value.leftDetected) + assertFalse(repository.handleDetection.value.rightDetected) + } finally { + repository.shutdown() + } + } + + @Test + fun `shutdown from connecting publication prevents post-publication connection effects`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val shutdownOnConnecting = async(Dispatchers.Unconfined) { + repository.connectionState.first { state -> + if (state == ConnectionState.Connecting) { + repository.shutdown() + true + } else { + false + } + } + } + + try { + val result = repository.connect(ScannedDevice("collector", "collector-address")) + + shutdownOnConnecting.await() + + assertTrue(result.isFailure) + assertTrue(logRepo.getLogsByEventType(LogEventType.CONNECT_START).isEmpty()) + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + } finally { + repository.shutdown() + } + } + + @Test + fun `shutdown from scanned-device publication prevents device-found side effects`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val shutdownOnDevices = async(Dispatchers.Unconfined) { + repository.scannedDevices.first { devices -> + if (devices.isNotEmpty()) { + repository.shutdown() + true + } else { + false + } + } + } + + try { + val result = repository.startScanning() + + shutdownOnDevices.await() + + assertTrue(result.isFailure) + assertTrue(logRepo.getLogsByEventType(LogEventType.DEVICE_FOUND).isEmpty()) + assertTrue(repository.scannedDevices.value.isEmpty()) + } finally { + repository.shutdown() + } + } + + @Test + fun `shutdown from timeout teardown prevents stale timeout side effects`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val shutdownOnDisconnected = async(Dispatchers.Unconfined) { + repository.connectionState.drop(1).first { state -> + if (state == ConnectionState.Disconnected) { + repository.shutdown() + true + } else { + false + } + } + } + + try { + val result = repository.scanAndConnect(timeoutMs = 50L) + + shutdownOnDisconnected.await() + + assertTrue(result.isFailure) + assertTrue(logRepo.getLogsByEventType(LogEventType.ERROR).isEmpty()) + repository.reconnectionRequested.test { + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } finally { + repository.shutdown() + } + } + + @Test + fun `shutdown from timeout log prevents reconnection side effects`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val shutdownOnTimeoutLog = async(Dispatchers.Unconfined) { + var shutdownTriggered = false + logRepo.logs.first { logs -> + if (!shutdownTriggered && logs.any { it.message == "Phantom scan and connect timed out" }) { + shutdownTriggered = true + repository.shutdown() + } + shutdownTriggered + } + } + + try { + repository.reconnectionRequested.test { + val result = repository.scanAndConnect(timeoutMs = 50L) + + shutdownOnTimeoutLog.await() + + assertTrue(result.isFailure) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } finally { + repository.shutdown() + } + } + + @Test + fun `shutdown from raw monitor deload publication prevents metric side effects`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + val shutdownOnDeload = async(Dispatchers.Unconfined) { + repository.deloadOccurredEvents.first { + repository.shutdown() + true + } + } + val monitor = monitorPacket( + ticks = 42, + posA = 1250, + velA = 320, + loadA = 1234, + posB = -750, + velB = -250, + loadB = 567, + status = 0x8000, + ) + + try { + repository.metricsFlow.test { + val result = repository.injectRawPacket(PhantomRawPacketKind.MONITOR, monitor) + + shutdownOnDeload.await() + + assertTrue(result.isFailure) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } finally { + repository.shutdown() + } + } + + @Test + fun `shutdown from raw ROM publication prevents later ROM and deload side effects`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val shutdownOnRom = async(Dispatchers.Unconfined) { + var shutdownTriggered = false + logRepo.logs.first { logs -> + if (!shutdownTriggered && logs.any { it.message == "Phantom raw monitor packet reported ROM violation" }) { + shutdownTriggered = true + repository.shutdown() + } + shutdownTriggered + } + } + val monitor = monitorPacket( + ticks = 42, + posA = 1250, + velA = 320, + loadA = 1234, + posB = -750, + velB = -250, + loadB = 567, + status = 0x800c, + ) + + try { + repository.metricsFlow.test { + val result = repository.injectRawPacket(PhantomRawPacketKind.MONITOR, monitor) + + shutdownOnRom.await() + + assertTrue(result.isFailure) + assertEquals( + 1, + logRepo.logs.value.count { it.message == "Phantom raw monitor packet reported ROM violation" }, + ) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + repository.deloadOccurredEvents.test { + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } finally { + repository.shutdown() + } + } + + @Test + fun `reentrant shutdown log does not recurse disconnect publication`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val shutdownOnDisconnectLog = async(Dispatchers.Unconfined) { + var shutdownTriggered = false + logRepo.logs.first { logs -> + if (!shutdownTriggered && logs.any { it.eventType == LogEventType.DISCONNECT }) { + shutdownTriggered = true + repository.shutdown() + } + shutdownTriggered + } + } + + try { + repository.shutdown() + shutdownOnDisconnectLog.await() + + assertEquals(1, logRepo.getLogsByEventType(LogEventType.DISCONNECT).size) + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + } finally { + repository.shutdown() + } + } + + @Test + fun `reentrant disconnect log does not duplicate cleanup`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + logRepo.clearAll() + val disconnectOnLog = async(Dispatchers.Unconfined) { + var disconnectTriggered = false + logRepo.logs.first { logs -> + if (!disconnectTriggered && logs.any { it.eventType == LogEventType.DISCONNECT }) { + disconnectTriggered = true + repository.disconnect() + } + disconnectTriggered + } + } + + repository.disconnect() + disconnectOnLog.await() + + assertEquals(1, logRepo.getLogsByEventType(LogEventType.DISCONNECT).size) + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + } finally { + repository.shutdown() + } + } + + @Test + fun `shutdown during disconnect cleanup claims terminal state`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + logRepo.clearAll() + val shutdownOnDisconnectLog = async(Dispatchers.Unconfined) { + logRepo.logs.first { logs -> + if (logs.any { it.message == "Disconnected phantom Vitruvian" }) { + repository.shutdown() + true + } else { + false + } + } + } + + repository.disconnect() + shutdownOnDisconnectLog.await() + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + assertTrue(repository.connect(ScannedDevice("terminal", "terminal-address")).isFailure) + assertEquals(1, logRepo.getLogsByEventType(LogEventType.DISCONNECT).size) + } finally { + repository.shutdown() + } + } + + @Test + fun `stopScanning does not log stale cleanup after a reentrant scan`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val restartedScan = async(Dispatchers.Unconfined) { + var result: Result? = null + repository.connectionState.drop(1).first { state -> + if (state == ConnectionState.Disconnected) { + result = repository.startScanning() + true + } else { + false + } + } + result + } + val scanning = async(Dispatchers.Default) { repository.startScanning() } + + try { + repository.connectionState.first { it == ConnectionState.Scanning } + logRepo.clearAll() + repository.stopScanning() + + assertTrue(restartedScan.await()!!.isSuccess) + assertTrue(scanning.await().isFailure) + assertEquals(ConnectionState.Scanning, repository.connectionState.value) + assertTrue(repository.scannedDevices.value.isNotEmpty()) + assertTrue(logRepo.getLogsByEventType(LogEventType.SCAN_STOP).isEmpty()) + } finally { + repository.shutdown() + } + } + + @Test + fun `stopScanning is ignored by timeout teardown`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val stopScanningOnTeardown = async(Dispatchers.Unconfined) { + repository.scannedDevices.drop(1).first { devices -> + if (devices.isEmpty()) { + repository.stopScanning() + true + } else { + false + } + } + } + + try { + val result = repository.scanAndConnect(timeoutMs = 200L) + stopScanningOnTeardown.await() + + assertTrue(result.isFailure) + assertEquals( + "Phantom scan and connect timed out after 200ms", + result.exceptionOrNull()?.message, + ) + assertTrue(logRepo.getLogsByEventType(LogEventType.SCAN_STOP).isEmpty()) + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + assertTrue(repository.scannedDevices.value.isEmpty()) + } finally { + repository.shutdown() + } + } + + @Test + fun `cancelConnection does not log stale cleanup after a reentrant connection`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val reconnected = async(Dispatchers.Unconfined) { + var result: Result? = null + repository.connectionState.drop(1).first { state -> + if (state == ConnectionState.Disconnected) { + result = repository.connect(ScannedDevice("new", "new-address")) + true + } else { + false + } + } + result + } + val connecting = async(Dispatchers.Default) { + repository.connect(ScannedDevice("old", "old-address")) + } + + try { + repository.connectionState.first { it == ConnectionState.Connecting } + logRepo.clearAll() + repository.cancelConnection() + + assertTrue(reconnected.await()!!.isSuccess) + assertTrue(connecting.await().isFailure) + assertEquals(ConnectionState.Connected("new", "new-address"), repository.connectionState.value) + assertTrue(logRepo.getLogsByEventType(LogEventType.DISCONNECT).none { it.message == "Cancelled phantom connection" }) + } finally { + repository.shutdown() + } + } + + @Test + fun `cancelConnection is ignored by timeout teardown`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val cancelConnectionOnTeardown = async(Dispatchers.Unconfined) { + repository.scannedDevices.drop(1).first { devices -> + if (devices.isEmpty()) { + repository.cancelConnection() + true + } else { + false + } + } + } + + try { + val result = repository.scanAndConnect(timeoutMs = 200L) + cancelConnectionOnTeardown.await() + + assertTrue(result.isFailure) + assertEquals( + "Phantom scan and connect timed out after 200ms", + result.exceptionOrNull()?.message, + ) + assertTrue(logRepo.getLogsByEventType(LogEventType.DISCONNECT).none { log -> + log.message == "Cancelled phantom connection" + }) + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + assertTrue(repository.scannedDevices.value.isEmpty()) + } finally { + repository.shutdown() + } + } + + @Test + fun `disconnect teardown rejects a reentrant scan`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + val restartedScan = async(Dispatchers.Unconfined) { + var result: Result? = null + repository.handleDetection.drop(1).first { detection -> + if (!detection.leftDetected) { + result = repository.startScanning() + true + } else { + false + } + } + result + } + logRepo.clearAll() + + repository.disconnect() + + assertTrue(restartedScan.await()!!.isFailure) + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + assertTrue(repository.scannedDevices.value.isEmpty()) + } finally { + repository.shutdown() + } + } + + @Test + fun `disconnect log rejects a reentrant scan during cleanup`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + var scanResult: Result? = null + val scanOnDisconnectLog = async(Dispatchers.Unconfined) { + logRepo.logs.first { logs -> + if (logs.any { it.eventType == LogEventType.DISCONNECT }) { + scanResult = repository.startScanning() + true + } else { + false + } + } + } + + try { + assertTrue(repository.scanAndConnect().isSuccess) + logRepo.clearAll() + + repository.disconnect() + assertTrue(scanOnDisconnectLog.await().any { it.eventType == LogEventType.DISCONNECT }) + + assertTrue(requireNotNull(scanResult).isFailure) + assertEquals(1, logRepo.getLogsByEventType(LogEventType.DISCONNECT).size) + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + assertTrue(repository.scannedDevices.value.isEmpty()) + } finally { + repository.shutdown() + } + } + + @Test + fun `disconnect teardown rejects reentrant producer controls`() = runTest { + val logRepo = ConnectionLogRepository() + val initialConfig = PhantomBleConfig(repDelayMs = 100L) + val repository = PhantomBleRepository(logRepo, initialConfig) + val replacement = PhantomBleConfig(loadScale = 2f, repDelayMs = 100L) + var startWorkoutResult: Result? = null + val startWorkoutOnTeardown = async(Dispatchers.Unconfined) { + repository.handleDetection.drop(1).first { detection -> + if (!detection.leftDetected && !detection.rightDetected) { + startWorkoutResult = repository.startWorkout(workoutParameters()) + true + } else { + false + } + } + } + val controlsOnTeardown = async(Dispatchers.Unconfined) { + repository.handleDetection.drop(1).first { detection -> + if (!detection.leftDetected && !detection.rightDetected) { + repository.startActiveWorkoutPolling() + repository.restartMonitorPolling() + repository.replaceConfig(replacement) + true + } else { + false + } + } + } + + try { + assertTrue(repository.scanAndConnect().isSuccess) + repository.stopPolling() + logRepo.clearAll() + + repository.disconnect() + + assertFalse(controlsOnTeardown.await().leftDetected) + assertFalse(startWorkoutOnTeardown.await().leftDetected) + assertTrue(requireNotNull(startWorkoutResult).isFailure) + assertEquals(initialConfig, repository.config.value) + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + assertEquals(HandleState.WaitingForRest, repository.handleState.value) + assertNull(repository.diagnostics.value) + assertNull(repository.heuristicData.value) + assertTrue(repository.scannedDevices.value.isEmpty()) + val logsAfterTeardown = logRepo.logs.value + assertTrue(logsAfterTeardown.none { log -> + log.message in setOf( + "Phantom workout started", + "Phantom config updated", + "Phantom monitor metric", + "Phantom heuristic update", + "Phantom rep notification", + ) + }) + + withContext(Dispatchers.Default) { delay(350L) } + assertEquals(logsAfterTeardown, logRepo.logs.value) + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + assertEquals(HandleState.WaitingForRest, repository.handleState.value) + assertNull(repository.diagnostics.value) + assertNull(repository.heuristicData.value) + assertTrue(repository.scannedDevices.value.isEmpty()) + } finally { + repository.shutdown() + } + } + + @Test + fun `timeout teardown rejects reentrant producer controls`() = runTest { + val logRepo = ConnectionLogRepository() + val initialConfig = PhantomBleConfig(repDelayMs = 100L) + val repository = PhantomBleRepository(logRepo, initialConfig) + val replacement = PhantomBleConfig(loadScale = 2f, repDelayMs = 100L) + var startWorkoutResult: Result? = null + val startWorkoutOnTeardown = async(Dispatchers.Unconfined) { + repository.scannedDevices.drop(1).first { devices -> + if (devices.isEmpty()) { + startWorkoutResult = repository.startWorkout(workoutParameters()) + true + } else { + false + } + } + } + val controlsOnTeardown = async(Dispatchers.Unconfined) { + repository.scannedDevices.drop(1).first { devices -> + if (devices.isEmpty()) { + repository.startActiveWorkoutPolling() + repository.restartMonitorPolling() + repository.replaceConfig(replacement) + true + } else { + false + } + } + } + + try { + assertTrue(repository.scanAndConnect(timeoutMs = 300L).isFailure) + + assertTrue(controlsOnTeardown.await().isEmpty()) + assertTrue(startWorkoutOnTeardown.await().isEmpty()) + assertTrue(requireNotNull(startWorkoutResult).isFailure) + assertEquals(initialConfig, repository.config.value) + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + assertEquals(HandleState.WaitingForRest, repository.handleState.value) + assertNull(repository.diagnostics.value) + assertNull(repository.heuristicData.value) + assertTrue(repository.scannedDevices.value.isEmpty()) + val logsAfterTeardown = logRepo.logs.value + assertTrue(logsAfterTeardown.none { log -> + log.message in setOf( + "Phantom workout started", + "Phantom config updated", + "Phantom monitor metric", + "Phantom heuristic update", + "Phantom rep notification", + ) + }) + + withContext(Dispatchers.Default) { delay(350L) } + assertEquals(logsAfterTeardown, logRepo.logs.value) + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + assertEquals(HandleState.WaitingForRest, repository.handleState.value) + assertNull(repository.diagnostics.value) + assertNull(repository.heuristicData.value) + assertTrue(repository.scannedDevices.value.isEmpty()) + } finally { + repository.shutdown() + } + } + + @Test + fun `disconnect teardown rejects reentrant diagnostic polling restart`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + val restartOnDiagnosticsClear = async(Dispatchers.Unconfined) { + repository.diagnostics.first { diagnostic -> + if (diagnostic == null) { + repository.restartDiagnosticPolling() + true + } else { + false + } + } + } + repository.disconnect() + restartOnDiagnosticsClear.await() + + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + assertNull(repository.diagnostics.value) + } finally { + repository.shutdown() + } + } + + @Test + fun `disconnect teardown rejects reentrant raw diagnostic and monitor injection`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val monitor = monitorPacket( + ticks = 42, + posA = 1250, + velA = 320, + loadA = 1234, + posB = -750, + velB = -250, + loadB = 567, + ) + val diagnostic = ByteArray(18).also { bytes -> + putUInt32LE(bytes, 0, 1234) + putUInt16LE(bytes, 4, 0x0001) + bytes[12] = 31 + bytes[13] = 32 + bytes[14] = 33 + bytes[15] = 34 + bytes[16] = 35 + bytes[17] = 36 + } + var diagnosticResult: Result? = null + var monitorResult: Result? = null + + try { + assertTrue(repository.scanAndConnect().isSuccess) + val injectOnDiagnosticsClear = async(Dispatchers.Unconfined) { + repository.diagnostics.first { packet -> + if (packet == null) { + diagnosticResult = repository.injectRawPacket(PhantomRawPacketKind.DIAGNOSTIC, diagnostic) + monitorResult = repository.injectRawPacket(PhantomRawPacketKind.MONITOR, monitor) + true + } else { + false + } + } + } + repository.metricsFlow.test { + repository.disconnect() + injectOnDiagnosticsClear.await() + + assertTrue(requireNotNull(diagnosticResult).isFailure) + assertTrue(requireNotNull(monitorResult).isFailure) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + assertNull(repository.diagnostics.value) + assertTrue(logRepo.logs.value.none { log -> + log.message in setOf( + "Phantom injected raw diagnostic packet", + "Phantom injected raw monitor packet", + ) + }) + } finally { + repository.shutdown() + } + } + + @Test + fun `stopWorkout from workout handle publication invalidates outer workout handoff`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo, PhantomBleConfig(repDelayMs = 100L)) + val stopOnGrabbed = async(Dispatchers.Unconfined) { + repository.handleState.first { state -> + if (state == HandleState.Grabbed) { + assertTrue(repository.stopWorkout().isSuccess) + true + } else { + false + } + } + } + + try { + assertTrue(repository.scanAndConnect().isSuccess) + logRepo.clearAll() + + val result = repository.startWorkout(workoutParameters()) + + stopOnGrabbed.await() + assertTrue(result.isFailure) + assertTrue(repository.connectionState.value is ConnectionState.Connected) + assertEquals(HandleState.Released, repository.handleState.value) + + val logsAfterStop = logRepo.logs.value + withContext(Dispatchers.Default) { delay(350L) } + assertEquals(logsAfterStop, logRepo.logs.value) + } finally { + repository.shutdown() + } + } + + @Test + fun `resetHandleState from workout handle publication invalidates outer workout handoff`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo, PhantomBleConfig(repDelayMs = 100L)) + val resetOnGrabbed = async(Dispatchers.Unconfined) { + repository.handleState.first { state -> + if (state == HandleState.Grabbed) { + repository.resetHandleState() + true + } else { + false + } + } + } + + try { + assertTrue(repository.scanAndConnect().isSuccess) + logRepo.clearAll() + + val result = repository.startWorkout(workoutParameters()) + + resetOnGrabbed.await() + assertTrue(result.isFailure) + assertTrue(repository.connectionState.value is ConnectionState.Connected) + assertEquals(HandleState.WaitingForRest, repository.handleState.value) + assertTrue(logRepo.logs.value.none { log -> + log.message == "Phantom workout started" || + log.message == "Phantom heuristic update" || + log.message == "Phantom rep notification" || + (log.message == "Phantom monitor metric" && log.details?.contains("load=10") == true) + }) + + repository.replaceConfig(PhantomBleConfig(loadScale = 2f, repDelayMs = 100L)) + logRepo.clearAll() + withContext(Dispatchers.Default) { delay(350L) } + assertTrue(logRepo.logs.value.none { log -> + log.message == "Phantom monitor metric" && log.details?.contains("load=20") == true + }) + } finally { + repository.shutdown() + } + } + + @Test + fun `handle detection publication preserves reentrant workout ownership`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo, PhantomBleConfig(repDelayMs = 100L)) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + logRepo.clearAll() + val startWorkoutOnDetection = async(Dispatchers.Unconfined) { + repository.handleDetection.drop(1).first { detection -> + if (!detection.leftDetected) { + assertTrue(repository.startWorkout(workoutParameters()).isSuccess) + true + } else { + false + } + } + } + + repository.enableHandleDetection(false) + startWorkoutOnDetection.await() + + assertTrue(repository.connectionState.value is ConnectionState.Connected) + assertEquals(HandleState.Grabbed, repository.handleState.value) + val metric = withContext(Dispatchers.Default) { + withTimeout(1_000L) { repository.metricsFlow.first() } + } + assertTrue(metric.loadA >= 10f, "detection handoff must preserve active workout metrics") + } finally { + repository.shutdown() + } + } + + @Test + fun `shutdown from workout handle publication prevents post-publication workout effects`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo, PhantomBleConfig(repDelayMs = 100L)) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + logRepo.clearAll() + val shutdownOnGrabbed = async(Dispatchers.Unconfined) { + repository.handleState.first { state -> + if (state == HandleState.Grabbed) { + repository.shutdown() + true + } else { + false + } + } + } + + val result = repository.startWorkout(workoutParameters()) + + shutdownOnGrabbed.await() + + assertTrue(result.isFailure) + assertTrue(logRepo.logs.value.none { it.message == "Phantom workout started" }) + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + } finally { + repository.shutdown() + } + } + + @Test + fun `handle control does not overwrite reentrant disconnect cleanup`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + logRepo.clearAll() + val disconnectOnHandleDetection = async(Dispatchers.Unconfined) { + repository.handleDetection.first { detection -> + if (!detection.leftDetected && !detection.rightDetected) { + repository.disconnect() + true + } else { + false + } + } + } + + repository.enableHandleDetection(false) + disconnectOnHandleDetection.await() + + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + assertFalse(repository.handleDetection.value.leftDetected) + assertFalse(repository.handleDetection.value.rightDetected) + assertEquals(HandleState.WaitingForRest, repository.handleState.value) + assertTrue(logRepo.logs.value.none { it.message == "Phantom handle detection disabled" }) + } finally { + repository.shutdown() + } + } + + @Test + fun `disconnect teardown rejects ordinary controls at final disconnected publication`() = runTest { + val logRepo = ConnectionLogRepository() + val initialConfig = PhantomBleConfig(repDelayMs = 100L) + val replacement = PhantomBleConfig(loadScale = 2f, repDelayMs = 100L) + val repository = PhantomBleRepository(logRepo, initialConfig) + var startWorkoutResult: Result? = null + var stopWorkoutResult: Result? = null + var colorResult: Result? = null + var workoutCommandResult: Result? = null + var initResult: Result? = null + var stopCommandResult: Result? = null + var rawPacketResult: Result? = null + val controlsOnDisconnect = async(Dispatchers.Unconfined) { + repository.connectionState.drop(1).first { state -> + if (state == ConnectionState.Disconnected) { + repository.enableHandleDetection(true) + repository.resetHandleState() + repository.enableJustLiftWaitingMode() + repository.startActiveWorkoutPolling() + repository.stopPolling() + repository.stopMonitorPollingOnly() + repository.restartMonitorPolling() + repository.restartDiagnosticPolling() + repository.startDiscoMode() + repository.stopDiscoMode() + repository.setLastColorSchemeIndex(7) + repository.replaceConfig(replacement) + startWorkoutResult = repository.startWorkout(workoutParameters()) + stopWorkoutResult = repository.stopWorkout() + colorResult = repository.setColorScheme(7) + workoutCommandResult = repository.sendWorkoutCommand(byteArrayOf(0x01)) + initResult = repository.sendInitSequence() + stopCommandResult = repository.sendStopCommand() + rawPacketResult = repository.injectRawPacket( + PhantomRawPacketKind.MONITOR, + monitorPacket( + ticks = 42, + posA = 1250, + velA = 320, + loadA = 1234, + posB = -750, + velB = -250, + loadB = 567, + ), + ) + true + } else { + false + } + } + } + + try { + assertTrue(repository.scanAndConnect().isSuccess) + logRepo.clearAll() + + repository.disconnect() + controlsOnDisconnect.await() + + assertTrue(requireNotNull(startWorkoutResult).isFailure) + assertTrue(requireNotNull(stopWorkoutResult).isFailure) + assertTrue(requireNotNull(colorResult).isFailure) + assertTrue(requireNotNull(workoutCommandResult).isFailure) + assertTrue(requireNotNull(initResult).isFailure) + assertTrue(requireNotNull(stopCommandResult).isFailure) + assertTrue(requireNotNull(rawPacketResult).isFailure) + assertFalse(repository.handleDetection.value.leftDetected) + assertFalse(repository.handleDetection.value.rightDetected) + assertEquals(HandleState.WaitingForRest, repository.handleState.value) + assertEquals(initialConfig, repository.config.value) + assertFalse(repository.discoModeActive.value) + assertTrue(logRepo.logs.value.none { log -> + log.message in setOf( + "Phantom handle detection enabled", + "Phantom Just Lift waiting mode armed", + "Phantom polling stopped", + "Phantom monitor polling stopped; diagnostics kept warm", + "Phantom disco mode stopped", + "Phantom config updated", + "Phantom workout started", + "Phantom workout stopped", + "Phantom color scheme set", + "Phantom received raw workout command", + "Phantom init sequence accepted", + "Phantom stop command accepted", + "Phantom injected raw monitor packet", + ) + }) + + assertTrue(repository.connect(ScannedDevice("post-cleanup", "post-cleanup-address")).isSuccess) + logRepo.clearAll() + repository.startDiscoMode() + repository.stopDiscoMode() + assertTrue( + logRepo.getLogsByEventType(LogEventType.COMMAND_SENT) + .any { it.message == "Phantom disco mode stopped" && it.details == "restoredScheme=0" }, + ) + } finally { + repository.shutdown() + } + } + + @Test + fun `command results fail when command log collector disconnects normally`() = runTest { + val commands = listOf Result>>( + "Phantom color scheme set" to { setColorScheme(7) }, + "Phantom received raw workout command" to { sendWorkoutCommand(byteArrayOf(0x01)) }, + "Phantom init sequence accepted" to { sendInitSequence() }, + "Phantom stop command accepted" to { sendStopCommand() }, + ) + + commands.forEach { (message, command) -> + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + try { + assertTrue(repository.scanAndConnect().isSuccess) + logRepo.clearAll() + val disconnectOnCommandLog = async(Dispatchers.Unconfined) { + var disconnectTriggered = false + logRepo.logs.first { logs -> + if (!disconnectTriggered && logs.any { it.message == message }) { + disconnectTriggered = true + repository.disconnect() + } + disconnectTriggered + } + } + + val result = command(repository) + + disconnectOnCommandLog.await() + assertTrue(result.isFailure, message) + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + } finally { + repository.shutdown() + } + } + } + + @Test + fun `replaceConfig aborts when config collector disconnects normally`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val replacement = PhantomBleConfig(loadScale = 2f, repDelayMs = 100L) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + logRepo.clearAll() + val disconnectOnConfig = async(Dispatchers.Unconfined) { + repository.config.first { config -> + if (config == replacement) { + repository.disconnect() + true + } else { + false + } + } + } + + repository.replaceConfig(replacement) + + disconnectOnConfig.await() + assertEquals(replacement, repository.config.value) + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + assertTrue(logRepo.logs.value.none { it.message == "Phantom config updated" }) + } finally { + repository.shutdown() + } + } + + @Test + fun `injectRawPacket parses legacy rep bytes through protocol parser`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + val raw = byteArrayOf( + 0x07, 0x00, // topCounter = 7 + 0x00, 0x00, + 0x05, 0x00, // completeCounter = 5 + 0x55, 0x66, 0x77, 0x11, // extra legacy bytes should still parse as legacy + ) + + repository.repEvents.test { + val result = repository.injectRawPacket(PhantomRawPacketKind.REP, raw) + assertTrue(result.isSuccess) + + val event = awaitItem() + assertEquals(7, event.topCounter) + assertEquals(5, event.completeCounter) + assertTrue(event.isLegacyFormat) + assertEquals(raw.toList(), event.rawData.toList()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `injectRawPacket parses opcode-prefixed rep bytes through protocol parser`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + val raw = byteArrayOf( + 0x02, // opcode prefix + 0x05, 0x00, 0x00, 0x00, // topCounter = 5 + 0x04, 0x00, 0x00, 0x00, // completeCounter = 4 + 0x00, 0x00, 0x80.toByte(), 0x3F, // rangeTop = 1.0f + 0x00, 0x00, 0x00, 0x00, // rangeBottom = 0.0f + 0x01, 0x00, // repsRomCount = 1 + 0x02, 0x00, // repsRomTotal = 2 + 0x03, 0x00, // repsSetCount = 3 + 0x04, 0x00, // repsSetTotal = 4 + ) + + repository.repEvents.test { + val result = repository.injectRawPacket(PhantomRawPacketKind.REP, raw, hasOpcodePrefix = true) + assertTrue(result.isSuccess) + + val event = awaitItem() + assertEquals(5, event.topCounter) + assertEquals(4, event.completeCounter) + assertEquals(1, event.repsRomCount) + assertEquals(3, event.repsSetCount) + assertFalse(event.isLegacyFormat) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `injectRawPacket parses heuristic bytes into heuristic state`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + val raw = ByteArray(48).also { bytes -> + putFloatLE(bytes, 0, 50f) + putFloatLE(bytes, 4, 80f) + putFloatLE(bytes, 24, 40f) + putFloatLE(bytes, 28, 60f) + } + + val result = repository.injectRawPacket(PhantomRawPacketKind.HEURISTIC, raw) + + assertTrue(result.isSuccess) + val heuristic = requireNotNull(repository.heuristicData.value) + assertEquals(50f, heuristic.concentric.kgAvg) + assertEquals(80f, heuristic.concentric.kgMax) + assertEquals(40f, heuristic.eccentric.kgAvg) + assertEquals(60f, heuristic.eccentric.kgMax) + } + + @Test + fun `injectRawPacket parses monitor bytes through monitor processor`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + val raw = monitorPacket( + ticks = 42, + posA = 1250, // 125.0mm + velA = 320, // 32.0mm/s + loadA = 1234, // 12.34kg + posB = -750, // -75.0mm + velB = -250, // -25.0mm/s + loadB = 567, // 5.67kg + ) + + repository.metricsFlow.test { + val result = repository.injectRawPacket(PhantomRawPacketKind.MONITOR, raw) + assertTrue(result.isSuccess) + + val metric = awaitItem() + assertEquals(42, metric.ticks) + assertEquals(12.34f, metric.loadA) + assertEquals(5.67f, metric.loadB) + assertEquals(125.0f, metric.positionA) + assertEquals(-75.0f, metric.positionB) + assertEquals(32.0, metric.velocityA) + assertEquals(-25.0, metric.velocityB) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `injectRawPacket parses diagnostic bytes into diagnostic state`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + val raw = ByteArray(18).also { bytes -> + putUInt32LE(bytes, 0, 1234) + putUInt16LE(bytes, 4, 0x0001) + bytes[12] = 31 + bytes[13] = 32 + bytes[14] = 33 + bytes[15] = 34 + bytes[16] = 35 + bytes[17] = 36 + } + + val result = repository.injectRawPacket(PhantomRawPacketKind.DIAGNOSTIC, raw) + + assertTrue(result.isSuccess) + val packet = repository.diagnostics.value + requireNotNull(packet) + assertEquals(1234, packet.runtimeSeconds) + assertEquals(listOf(1, 0, 0, 0), packet.faultWords) + assertEquals(listOf(31, 32, 33, 34, 35, 36), packet.temperatures) + assertTrue(packet.hasFaults) + } + + @Test + fun `injectRawPacket returns failure for invalid monitor bytes`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + + val result = repository.injectRawPacket(PhantomRawPacketKind.MONITOR, byteArrayOf(1, 2, 3)) + + assertFalse(result.isSuccess) + } + + @Test + fun `replaceConfig scales generated phantom metrics`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + + repository.metricsFlow.test { + assertTrue(repository.scanAndConnect().isSuccess) + awaitItem() + repository.replaceConfig( + PhantomBleConfig( + loadScale = 2f, + velocityScale = 3.0, + positionScale = 0.5f, + repDelayMs = 100L, + ), + ) + val metric = awaitItem() + + assertTrue(metric.loadA >= 3.0f, "loadA should reflect doubled inactive phantom load") + assertTrue(kotlin.math.abs(metric.positionA) <= 325.0f, "positionA should reflect halved range") + assertTrue(kotlin.math.abs(metric.velocityA) <= 750.0, "velocityA should reflect tripled range") + repository.disconnect() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `fixed rep workout completes at target and stops simulation`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository( + logRepo, + PhantomBleConfig(repDelayMs = 100L), + ) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + repository.repEvents.test { + assertTrue(repository.startWorkout(workoutParameters().copy(reps = 2)).isSuccess) + + val first = awaitItem() + val second = awaitItem() + assertEquals(1, first.repsSetCount) + assertEquals(2, second.repsSetCount) + assertEquals(2, second.repsSetTotal) + assertTrue( + logRepo.getLogsByEventType(LogEventType.COMMAND_RESPONSE) + .any { it.message == "Phantom target reps reached" }, + ) + + withContext(Dispatchers.Default) { delay(250L) } + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } finally { + repository.shutdown() + } + } + + @Test + fun `replaceConfig restarts an active rep simulation`() = runTest { + val repository = PhantomBleRepository( + ConnectionLogRepository(), + PhantomBleConfig(repDelayMs = 100L), + ) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + repository.repEvents.test { + assertTrue(repository.startWorkout(workoutParameters().copy(reps = 3)).isSuccess) + assertEquals(1, awaitItem().repsSetCount) + + repository.replaceConfig(PhantomBleConfig(repDelayMs = 100L)) + + val restarted = awaitItem() + assertEquals(1, restarted.repsSetCount) + assertEquals(3, restarted.repsSetTotal) + cancelAndIgnoreRemainingEvents() + } + } finally { + repository.shutdown() + } + } + + @Test + fun `replaceConfig does not restart a completed fixed rep simulation`() = runTest { + val repository = PhantomBleRepository( + ConnectionLogRepository(), + PhantomBleConfig(repDelayMs = 100L), + ) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + repository.repEvents.test { + assertTrue(repository.startWorkout(workoutParameters().copy(reps = 2)).isSuccess) + assertEquals(1, awaitItem().repsSetCount) + assertEquals(2, awaitItem().repsSetCount) + + withContext(Dispatchers.Default) { delay(250L) } + repository.replaceConfig(PhantomBleConfig(repDelayMs = 100L)) + withContext(Dispatchers.Default) { delay(250L) } + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } finally { + repository.shutdown() + } + } + + @Test + fun `replaceConfig immediately after final rep does not duplicate completed sequence`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository( + logRepo, + PhantomBleConfig(repDelayMs = 100L), + ) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + val replaceOnFinalRep = async(Dispatchers.Unconfined) { + repository.repEvents.first { event -> + if (event.repsSetCount == 2) { + repository.replaceConfig(PhantomBleConfig(repDelayMs = 100L)) + true + } else { + false + } + } + } + repository.repEvents.test { + assertTrue(repository.startWorkout(workoutParameters().copy(reps = 2)).isSuccess) + assertEquals(1, awaitItem().repsSetCount) + assertEquals(2, awaitItem().repsSetCount) + + replaceOnFinalRep.await() + withContext(Dispatchers.Default) { delay(250L) } + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } finally { + repository.shutdown() + } + } + + @Test + fun `replaceConfig does not bind a restarted rep producer to a reentrant connection`() = runTest { + val repository = PhantomBleRepository( + ConnectionLogRepository(), + PhantomBleConfig(repDelayMs = 500L), + ) + val replacement = PhantomBleConfig(repDelayMs = 100L) + val reconnectOnConfig = async(Dispatchers.Unconfined) { + var result: Result? = null + repository.config.first { config -> + if (config == replacement) { + result = repository.connect(ScannedDevice("replacement", "replacement-address")) + true + } else { + false + } + } + result + } + + try { + repository.repEvents.test { + assertTrue(repository.scanAndConnect().isSuccess) + assertTrue( + repository.startWorkout( + workoutParameters().copy(isAMRAP = true), + ).isSuccess, + ) + + repository.replaceConfig(replacement) + assertTrue(reconnectOnConfig.await()!!.isSuccess) + withContext(Dispatchers.Default) { delay(600L) } + + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } finally { + repository.shutdown() + } + } + + @Test + fun `AMRAP workout continues past requested rep count`() = runTest { + val repository = PhantomBleRepository( + ConnectionLogRepository(), + PhantomBleConfig(repDelayMs = 100L), + ) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + repository.repEvents.test { + assertTrue( + repository.startWorkout( + workoutParameters().copy(reps = 2, isAMRAP = true), + ).isSuccess, + ) + + val first = awaitItem() + val second = awaitItem() + val third = awaitItem() + assertEquals(1, first.repsSetCount) + assertEquals(2, second.repsSetCount) + assertEquals(2, third.repsSetCount) + assertEquals(2, third.repsSetTotal) + cancelAndIgnoreRemainingEvents() + } + } finally { + repository.shutdown() + } + } + + @Test + fun `direct reconnect suppresses stale AMRAP rep from previous connection`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository( + logRepo, + PhantomBleConfig(repDelayMs = 400L), + ) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + repository.repEvents.test { + assertTrue( + repository.startWorkout( + workoutParameters().copy(reps = 2, isAMRAP = true), + ).isSuccess, + ) + awaitItem() + + val reconnecting = async(Dispatchers.Default) { + repository.connect(ScannedDevice("reconnected", "reconnected-address")) + } + repository.connectionState.first { it == ConnectionState.Connecting } + assertTrue(reconnecting.await().isSuccess) + assertEquals( + ConnectionState.Connected("reconnected", "reconnected-address"), + repository.connectionState.value, + ) + + withContext(Dispatchers.Default) { delay(500L) } + expectNoEvents() + assertTrue( + logRepo.getLogsByEventType(LogEventType.REP_RECEIVED) + .none { it.message == "Phantom rep notification" && it.details?.contains("rep=2/2") == true }, + ) + cancelAndIgnoreRemainingEvents() + } + } finally { + repository.shutdown() + } + } + + @Test + fun `direct reconnect suppresses stale scheduled metric generation`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val reconnected = async(Dispatchers.Unconfined) { + repository.metricsFlow.first { metric -> + if (metric.loadA >= 2f) { + repository.connect(ScannedDevice("metric-reconnected", "metric-reconnected-address")) + true + } else { + false + } + } + } + + try { + assertTrue(repository.scanAndConnect().isSuccess) + assertTrue(repository.startWorkout(workoutParameters()).isSuccess) + reconnected.await() + + logRepo.clearAll() + withContext(Dispatchers.Default) { delay(300L) } + + assertEquals(ConnectionState.Connected("metric-reconnected", "metric-reconnected-address"), repository.connectionState.value) + assertTrue( + logRepo.getLogsByEventType(LogEventType.NOTIFICATION) + .none { it.message == "Phantom monitor metric" && it.details?.contains("load=10") == true }, + ) + } finally { + repository.shutdown() + } + } + + @Test + fun `direct reconnect suppresses stale scheduled diagnostic generation`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val reconnected = async(Dispatchers.Unconfined) { + logRepo.logs.first { logs -> + if (logs.any { it.message == "Phantom diagnostic heartbeat" }) { + repository.connect(ScannedDevice("diagnostic-reconnected", "diagnostic-reconnected-address")) + true + } else { + false + } + } + } + + try { + repository.connect(ScannedDevice("initial", "initial-address")) + reconnected.await() + + logRepo.clearAll() + withContext(Dispatchers.Default) { delay(300L) } + + assertEquals( + ConnectionState.Connected("diagnostic-reconnected", "diagnostic-reconnected-address"), + repository.connectionState.value, + ) + assertTrue(logRepo.getLogsByEventType(LogEventType.DIAGNOSTIC).isEmpty()) + } finally { + repository.shutdown() + } + } + + @Test + fun `direct reconnect suppresses stale scheduled heartbeat generation`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val reconnected = async(Dispatchers.Unconfined) { + logRepo.logs.first { logs -> + if (logs.any { it.message == "Phantom heartbeat" }) { + repository.connect(ScannedDevice("heartbeat-reconnected", "heartbeat-reconnected-address")) + true + } else { + false + } + } + } + + try { + repository.connect(ScannedDevice("initial", "initial-address")) + reconnected.await() + + withContext(Dispatchers.Default) { delay(100L) } + logRepo.clearAll() + withContext(Dispatchers.Default) { delay(300L) } + + assertEquals( + ConnectionState.Connected("heartbeat-reconnected", "heartbeat-reconnected-address"), + repository.connectionState.value, + ) + assertTrue(logRepo.getLogsByEventType(LogEventType.HEARTBEAT).isEmpty()) + } finally { + repository.shutdown() + } + } + + @Test + fun `raw monitor injection preserves entry generation across reconnect`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + val monitor = monitorPacket( + ticks = 42, + posA = 1250, + velA = 320, + loadA = 1234, + posB = -750, + velB = -250, + loadB = 567, + ) + val reconnectOnMetric = async(Dispatchers.Unconfined) { + repository.metricsFlow.first { metric -> + if (metric.ticks == 42L) { + repository.connect(ScannedDevice("raw-reconnected", "raw-reconnected-address")) + true + } else { + false + } + } + } + + try { + assertTrue(repository.scanAndConnect().isSuccess) + assertTrue(repository.injectRawPacket(PhantomRawPacketKind.MONITOR, monitor).isFailure) + reconnectOnMetric.await() + assertEquals( + ConnectionState.Connected("raw-reconnected", "raw-reconnected-address"), + repository.connectionState.value, + ) + } finally { + repository.shutdown() + } + } + + @Test + fun `nested raw monitor route preserves outer callback generation`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val outerMonitor = monitorPacket( + ticks = 42, + posA = 1250, + velA = 320, + loadA = 1234, + posB = -750, + velB = -250, + loadB = 567, + status = 0x800c, + ) + val nestedMonitor = monitorPacket( + ticks = 43, + posA = 1250, + velA = 320, + loadA = 1234, + posB = -750, + velB = -250, + loadB = 567, + ) + val nestedRoute = async(Dispatchers.Unconfined) { + var routeTriggered = false + logRepo.logs.first { logs -> + if (!routeTriggered && logs.any { it.message == "Phantom raw monitor packet reported ROM violation" }) { + routeTriggered = true + repository.disconnect() + assertTrue(repository.injectRawPacket(PhantomRawPacketKind.MONITOR, nestedMonitor).isSuccess) + } + routeTriggered + } + } + + try { + repository.deloadOccurredEvents.test { + val result = repository.injectRawPacket(PhantomRawPacketKind.MONITOR, outerMonitor) + + nestedRoute.await() + assertTrue(result.isFailure) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } finally { + repository.shutdown() + } + } + + @Test + fun `stopping workout races scheduled rep publication without post-stop rep`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository( + logRepo, + PhantomBleConfig(repDelayMs = 100L), + ) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + logRepo.clearAll() + val stopAfterRep = async(Dispatchers.Default) { + logRepo.logs.first { logs -> + logs.any { + it.eventType == LogEventType.REP_RECEIVED && it.message == "Phantom rep notification" + } + } + repository.stopWorkout() + } + + assertTrue(repository.startWorkout(workoutParameters()).isSuccess) + stopAfterRep.await() + assertEquals(HandleState.Released, repository.handleState.value) + val repLogsAfterStop = logRepo.getLogsByEventType(LogEventType.REP_RECEIVED).size + + withContext(Dispatchers.Default) { delay(200L) } + assertEquals(repLogsAfterStop, logRepo.getLogsByEventType(LogEventType.REP_RECEIVED).size) + } finally { + repository.shutdown() + } + } + + @Test + fun `sendStopCommand preserves active polling until stopWorkout cancels every producer`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository( + logRepo, + PhantomBleConfig(repDelayMs = 100L), + ) + val pollingMessages = listOf( + "Phantom monitor metric", + "Phantom heuristic update", + "Phantom diagnostic heartbeat", + "Phantom heartbeat", + "Phantom rep notification", + ) + fun pollingCounts(): Map = pollingMessages.associateWith { message -> + logRepo.logs.value.count { it.message == message } + } + + try { + assertTrue(repository.scanAndConnect().isSuccess) + assertTrue( + repository.startWorkout(workoutParameters().copy(isAMRAP = true)).isSuccess, + ) + + withContext(Dispatchers.Default) { + withTimeout(3_500L) { + logRepo.logs.first { logs -> + pollingMessages.all { message -> logs.any { it.message == message } } + } + } + } + val countsBeforeSendStop = pollingCounts() + assertTrue(countsBeforeSendStop.values.all { it > 0 }) + + assertTrue(repository.sendStopCommand().isSuccess) + assertTrue(repository.connectionState.value is ConnectionState.Connected) + assertEquals(HandleState.Grabbed, repository.handleState.value) + val countsAfterSendStop = withContext(Dispatchers.Default) { + withTimeout(3_500L) { + logRepo.logs.first { logs -> + pollingMessages.all { message -> + logs.count { it.message == message } > countsBeforeSendStop.getValue(message) + } + } + pollingCounts() + } + } + pollingMessages.forEach { message -> + assertTrue( + countsAfterSendStop.getValue(message) > countsBeforeSendStop.getValue(message), + "$message should remain active after sendStopCommand", + ) + } + + assertTrue(repository.stopWorkout().isSuccess) + assertTrue(repository.connectionState.value is ConnectionState.Connected) + assertEquals(HandleState.Released, repository.handleState.value) + val countsAfterStop = pollingCounts() + + withContext(Dispatchers.Default) { delay(2_250L) } + assertEquals(countsAfterStop, pollingCounts()) + } finally { + repository.shutdown() + } + } + + @Test + fun `stopMonitorPollingOnly stops metrics without stopping other polling`() = runTest { + val repository = PhantomBleRepository( + ConnectionLogRepository(), + PhantomBleConfig(repDelayMs = 100L), + ) + + try { + repository.metricsFlow.test { + assertTrue(repository.scanAndConnect().isSuccess) + assertTrue(awaitItem().loadA < 2f) + assertTrue(repository.startWorkout(workoutParameters()).isSuccess) + assertTrue(awaitItem().loadA >= 2f) + + repository.stopMonitorPollingOnly() + + withContext(Dispatchers.Default) { delay(500L) } + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } finally { + repository.shutdown() + } + } + + @Test + fun `stopMonitorPollingOnly keeps rep heuristic diagnostics and heartbeat active`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo, PhantomBleConfig(repDelayMs = 100L)) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + repository.repEvents.test { + assertTrue(repository.startWorkout(workoutParameters()).isSuccess) + withContext(Dispatchers.Default) { delay(50L) } + assertTrue(repository.heuristicData.value != null) + val heuristicTimestamp = requireNotNull(repository.heuristicData.value).timestamp + val diagnosticTimestamp = requireNotNull(repository.diagnostics.value).receivedAtMillis + + repository.stopMonitorPollingOnly() + val heartbeatCountAfterStop = logRepo.getLogsByEventType(LogEventType.HEARTBEAT).size + + withContext(Dispatchers.Default) { delay(300L) } + assertTrue(logRepo.getLogsByEventType(LogEventType.REP_RECEIVED).isNotEmpty()) + awaitItem() + assertTrue(requireNotNull(repository.heuristicData.value).timestamp > heuristicTimestamp) + withContext(Dispatchers.Default) { delay(2_200L) } + assertTrue(requireNotNull(repository.diagnostics.value).receivedAtMillis > diagnosticTimestamp) + assertTrue(logRepo.getLogsByEventType(LogEventType.HEARTBEAT).size > heartbeatCountAfterStop) + cancelAndIgnoreRemainingEvents() + } + } finally { + repository.shutdown() + } + } + + @Test + fun `scanAndConnect timeout returns failure and requests reconnection`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + + try { + repository.reconnectionRequested.test { + val result = repository.scanAndConnect(timeoutMs = 50L) + + assertTrue(result.isFailure) + val request = awaitItem() + assertEquals(PhantomBleRepository.PHANTOM_DEVICE_NAME, request.deviceName) + assertEquals(PhantomBleRepository.PHANTOM_DEVICE_ADDRESS, request.deviceAddress) + assertEquals("connection_timeout", request.reason) + cancelAndIgnoreRemainingEvents() + } + } finally { + repository.shutdown() + } + } + + @Test + fun `timed out scan does not tear down an explicitly started newer scan`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + + try { + repository.reconnectionRequested.test { + val timedOut = async(Dispatchers.Default) { + repository.scanAndConnect(timeoutMs = 250L) + } + repository.connectionState.first { it == ConnectionState.Connecting } + + repository.stopScanning() + val newerScan = async(Dispatchers.Default) { repository.startScanning() } + repository.connectionState.first { it == ConnectionState.Scanning } + + assertTrue(timedOut.await().isFailure) + assertTrue(newerScan.await().isSuccess) + assertTrue(repository.scannedDevices.value.isNotEmpty()) + assertEquals(ConnectionState.Scanning, repository.connectionState.value) + expectNoEvents() + assertTrue(logRepo.getLogsByEventType(LogEventType.ERROR).isEmpty()) + cancelAndIgnoreRemainingEvents() + } + } finally { + repository.shutdown() + } + } + + @Test + fun `shutdown makes scanAndConnect return failure without timeout side effects`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + + repository.shutdown() + val logsAfterShutdown = logRepo.logs.value.size + + repository.reconnectionRequested.test { + val result = repository.scanAndConnect(timeoutMs = 50L) + + assertTrue(result.isFailure) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + + assertEquals(logsAfterShutdown, logRepo.logs.value.size) + assertTrue(logRepo.getLogsByEventType(LogEventType.ERROR).isEmpty()) + } + + @Test + fun `scanAndConnect propagates caller timeout without requesting reconnection`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + + try { + assertFailsWith { + withTimeout(50L) { + repository.scanAndConnect(timeoutMs = 10_000L) + } + } + assertTrue(logRepo.getLogsByEventType(LogEventType.ERROR).isEmpty()) + } finally { + repository.shutdown() + } + } + + @Test + fun `disco mode requires connection and clears on workout and disconnect`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + + try { + repository.startDiscoMode() + assertFalse(repository.discoModeActive.value) + + assertTrue(repository.scanAndConnect().isSuccess) + repository.startDiscoMode() + assertTrue(repository.discoModeActive.value) + + assertTrue(repository.startWorkout(workoutParameters()).isSuccess) + assertFalse(repository.discoModeActive.value) + + repository.startDiscoMode() + assertFalse(repository.discoModeActive.value) + repository.disconnect() + assertFalse(repository.discoModeActive.value) + } finally { + repository.shutdown() + } + } + + @Test + fun `shutdown cancels polling jobs and clears lifecycle state`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository( + logRepo, + PhantomBleConfig(repDelayMs = 100L), + ) + + assertTrue(repository.scanAndConnect().isSuccess) + assertTrue(repository.startWorkout(workoutParameters()).isSuccess) + withContext(Dispatchers.Default) { delay(50L) } + assertFalse(repository.discoModeActive.value) + assertTrue(repository.scannedDevices.value.isNotEmpty()) + assertTrue(repository.diagnostics.value != null) + assertTrue(repository.heuristicData.value != null) + + repository.shutdown() + val diagnosticLogsAfterShutdown = logRepo.getLogsByEventType(LogEventType.DIAGNOSTIC).size + val heartbeatLogsAfterShutdown = logRepo.getLogsByEventType(LogEventType.HEARTBEAT).size + withContext(Dispatchers.Default) { delay(350L) } + + assertEquals(diagnosticLogsAfterShutdown, logRepo.getLogsByEventType(LogEventType.DIAGNOSTIC).size) + assertEquals(heartbeatLogsAfterShutdown, logRepo.getLogsByEventType(LogEventType.HEARTBEAT).size) + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + assertFalse(repository.handleDetection.value.leftDetected) + assertFalse(repository.handleDetection.value.rightDetected) + assertEquals(HandleState.WaitingForRest, repository.handleState.value) + assertFalse(repository.discoModeActive.value) + assertNull(repository.diagnostics.value) + assertTrue(repository.scannedDevices.value.isEmpty()) + assertNull(repository.heuristicData.value) + } + + @Test + fun `shutdown makes non-suspend controls no-ops`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + + repository.shutdown() + + repository.enableHandleDetection(true) + repository.resetHandleState() + repository.enableJustLiftWaitingMode() + repository.startDiscoMode() + repository.stopDiscoMode() + + assertFalse(repository.handleDetection.value.leftDetected) + assertFalse(repository.handleDetection.value.rightDetected) + assertEquals(HandleState.WaitingForRest, repository.handleState.value) + assertFalse(repository.discoModeActive.value) + } + + @Test + fun `shutdown rejects stop and color commands and preserves config`() = runTest { + val initialConfig = PhantomBleConfig(repDelayMs = 100L) + val repository = PhantomBleRepository(ConnectionLogRepository(), initialConfig) + + assertTrue(repository.scanAndConnect().isSuccess) + assertTrue(repository.startWorkout(workoutParameters()).isSuccess) + repository.shutdown() + + val stopResult = repository.stopWorkout() + val sendStopResult = repository.sendStopCommand() + val setColorResult = repository.setColorScheme(7) + repository.setLastColorSchemeIndex(7) + repository.replaceConfig(PhantomBleConfig(loadScale = 2f, repDelayMs = 100L)) + + assertTrue(stopResult.isFailure) + assertTrue(sendStopResult.isFailure) + assertTrue(setColorResult.isFailure) + assertEquals(initialConfig, repository.config.value) + assertEquals(HandleState.WaitingForRest, repository.handleState.value) + } + + @Test + fun `shutdown seals remaining suspend controls without post-terminal logs`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + + repository.shutdown() + val logsAfterShutdown = logRepo.logs.value.size + + repository.stopScanning() + repository.cancelConnection() + repository.disconnect() + val workoutResult = repository.sendWorkoutCommand(byteArrayOf(0x01)) + val initResult = repository.sendInitSequence() + val stopResult = repository.sendStopCommand() + repository.stopPolling() + repository.stopMonitorPollingOnly() + repository.shutdown() + + assertTrue(workoutResult.isFailure) + assertTrue(initResult.isFailure) + assertTrue(stopResult.isFailure) + assertEquals(logsAfterShutdown, logRepo.logs.value.size) + } + + @Test + fun `shutdown prevents scheduled metric emissions`() = runTest { + val repository = PhantomBleRepository( + ConnectionLogRepository(), + PhantomBleConfig(repDelayMs = 100L), + ) + + try { + repository.metricsFlow.test { + assertTrue(repository.scanAndConnect().isSuccess) + awaitItem() + assertTrue(repository.startWorkout(workoutParameters()).isSuccess) + awaitItem() + + repository.shutdown() + withContext(Dispatchers.Default) { delay(350L) } + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } finally { + repository.shutdown() + } + } + + @Test + fun `shutdown prevents scheduled rep emissions`() = runTest { + val repository = PhantomBleRepository( + ConnectionLogRepository(), + PhantomBleConfig(repDelayMs = 100L), + ) + + try { + repository.repEvents.test { + assertTrue(repository.scanAndConnect().isSuccess) + assertTrue(repository.startWorkout(workoutParameters()).isSuccess) + awaitItem() + + repository.shutdown() + withContext(Dispatchers.Default) { delay(200L) } + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } finally { + repository.shutdown() + } + } + + @Test + fun `shutdown prevents raw packet routes from publishing`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val monitor = monitorPacket( + ticks = 42, + posA = 1250, + velA = 320, + loadA = 1234, + posB = -750, + velB = -250, + loadB = 567, + ) + val rep = byteArrayOf( + 0x07, 0x00, + 0x00, 0x00, + 0x05, 0x00, + 0x55, 0x66, 0x77, 0x11, + ) + val diagnostic = ByteArray(18).also { bytes -> + putUInt32LE(bytes, 0, 1234) + putUInt16LE(bytes, 4, 0x0001) + bytes[12] = 31 + bytes[13] = 32 + bytes[14] = 33 + bytes[15] = 34 + bytes[16] = 35 + bytes[17] = 36 + } + val heuristic = ByteArray(48).also { bytes -> + putFloatLE(bytes, 0, 50f) + putFloatLE(bytes, 4, 80f) + putFloatLE(bytes, 24, 40f) + putFloatLE(bytes, 28, 60f) + } + + repository.shutdown() + val logsAfterShutdown = logRepo.logs.value.size + + repository.metricsFlow.test { + assertTrue(repository.injectRawPacket(PhantomRawPacketKind.MONITOR, monitor).isFailure) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + repository.repEvents.test { + assertTrue(repository.injectRawPacket(PhantomRawPacketKind.REP, rep).isFailure) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + assertTrue(repository.injectRawPacket(PhantomRawPacketKind.DIAGNOSTIC, diagnostic).isFailure) + assertTrue(repository.injectRawPacket(PhantomRawPacketKind.HEURISTIC, heuristic).isFailure) + assertNull(repository.diagnostics.value) + assertNull(repository.heuristicData.value) + assertEquals(logsAfterShutdown, logRepo.logs.value.size) + } + + @Test + fun `stopWorkout rejects scanning without invalidating the scan attempt`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val scanning = async(Dispatchers.Default) { repository.startScanning() } + + try { + repository.connectionState.first { it == ConnectionState.Scanning } + logRepo.clearAll() + + val result = repository.stopWorkout() + + assertTrue(result.isFailure) + assertEquals(ConnectionState.Scanning, repository.connectionState.value) + assertTrue(logRepo.logs.value.none { it.message == "Phantom workout stopped" }) + assertTrue(scanning.await().isSuccess) + } finally { + repository.shutdown() + } + } + + @Test + fun `stopWorkout rejects connecting without invalidating the connection attempt`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val connecting = async(Dispatchers.Default) { + repository.connect(ScannedDevice("stop-race", "stop-race-address")) + } + + try { + repository.connectionState.first { it == ConnectionState.Connecting } + logRepo.clearAll() + + val result = repository.stopWorkout() + + assertTrue(result.isFailure) + assertEquals(ConnectionState.Connecting, repository.connectionState.value) + assertTrue(logRepo.logs.value.none { it.message == "Phantom workout stopped" }) + assertTrue(connecting.await().isSuccess) + } finally { + repository.shutdown() + } + } + + @Test + fun `connected collector reset preserves waiting handle state`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + val resetOnConnected = async(Dispatchers.Unconfined) { + repository.connectionState.first { state -> + if (state is ConnectionState.Connected) { + repository.resetHandleState() + true + } else { + false + } + } + } + + try { + assertTrue(repository.connect(ScannedDevice("reset-collector", "reset-collector-address")).isSuccess) + resetOnConnected.await() + + assertTrue(repository.connectionState.value is ConnectionState.Connected) + assertEquals(HandleState.WaitingForRest, repository.handleState.value) + } finally { + repository.shutdown() + } + } + + @Test + fun `connected collector Just Lift waiting mode preserves waiting handle state`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + val enableJustLiftOnConnected = async(Dispatchers.Unconfined) { + repository.connectionState.first { state -> + if (state is ConnectionState.Connected) { + repository.enableJustLiftWaitingMode() + true + } else { + false + } + } + } + + try { + assertTrue(repository.connect(ScannedDevice("just-lift-collector", "just-lift-collector-address")).isSuccess) + enableJustLiftOnConnected.await() + + assertTrue(repository.connectionState.value is ConnectionState.Connected) + assertEquals(HandleState.WaitingForRest, repository.handleState.value) + } finally { + repository.shutdown() + } + } + + @Test + fun `connected collector disabling handle detection preserves disabled state`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + val disableDetectionOnConnected = async(Dispatchers.Unconfined) { + repository.connectionState.first { state -> + if (state is ConnectionState.Connected) { + repository.enableHandleDetection(false) + true + } else { + false + } + } + } + + try { + assertTrue(repository.connect(ScannedDevice("detection-collector", "detection-collector-address")).isSuccess) + disableDetectionOnConnected.await() + + assertTrue(repository.connectionState.value is ConnectionState.Connected) + assertFalse(repository.handleDetection.value.leftDetected) + assertFalse(repository.handleDetection.value.rightDetected) + assertEquals(HandleState.Released, repository.handleState.value) + } finally { + repository.shutdown() + } + } + + @Test + fun `reset handle preserves active polling across monitor restart and config replacement`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + repository.startActiveWorkoutPolling() + withContext(Dispatchers.Default) { + withTimeout(1_000L) { repository.metricsFlow.first { it.loadA >= 2f } } + } + + repository.resetHandleState() + assertEquals(HandleState.WaitingForRest, repository.handleState.value) + + val metricAfterRestart = async(Dispatchers.Unconfined) { + withTimeout(1_000L) { repository.metricsFlow.first { it.loadA >= 2f } } + } + repository.restartMonitorPolling() + metricAfterRestart.await() + + val metricAfterReplacement = async(Dispatchers.Unconfined) { + withTimeout(1_000L) { repository.metricsFlow.first { it.loadA >= 10f } } + } + repository.replaceConfig(PhantomBleConfig(loadScale = 2f)) + metricAfterReplacement.await() + } finally { + repository.shutdown() + } + } + + @Test + fun `stopWorkout reserves cleanup before publishing stop log`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo, PhantomBleConfig(repDelayMs = 100L)) + var startWorkoutResult: Result? = null + val controlsOnStopLog = async(Dispatchers.Unconfined) { + logRepo.logs.first { logs -> + if (logs.any { it.message == "Phantom workout stopped" }) { + startWorkoutResult = repository.startWorkout(workoutParameters()) + repository.startActiveWorkoutPolling() + true + } else { + false + } + } + } + + try { + assertTrue(repository.scanAndConnect().isSuccess) + assertTrue(repository.startWorkout(workoutParameters()).isSuccess) + logRepo.clearAll() + + assertTrue(repository.stopWorkout().isSuccess) + controlsOnStopLog.await() + + assertTrue(requireNotNull(startWorkoutResult).isFailure) + assertEquals(HandleState.Released, repository.handleState.value) + logRepo.clearAll() + repository.restartMonitorPolling() + withContext(Dispatchers.Default) { delay(350L) } + assertTrue(logRepo.logs.value.none { log -> + log.message == "Phantom monitor metric" && log.details?.contains("load=7.5") == true + }) + } finally { + repository.shutdown() + } + } + + @Test + fun `scan reservation rejects raw and disco controls before scanning publication`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val monitor = monitorPacket( + ticks = 42, + posA = 1250, + velA = 320, + loadA = 1234, + posB = -750, + velB = -250, + loadB = 567, + ) + var rawResult: Result? = null + val controlsOnClear = async(Dispatchers.Unconfined) { + repository.scannedDevices.drop(1).first { devices -> + if (devices.isEmpty()) { + assertNull(repository.diagnostics.value) + assertNull(repository.heuristicData.value) + rawResult = repository.injectRawPacket(PhantomRawPacketKind.MONITOR, monitor) + repository.startDiscoMode() + true + } else { + false + } + } + } + + try { + assertTrue(repository.scanAndConnect().isSuccess) + repository.stopPolling() + logRepo.clearAll() + + val scanning = async(Dispatchers.Default) { repository.startScanning() } + controlsOnClear.await() + + assertTrue(requireNotNull(rawResult).isFailure) + assertFalse(repository.discoModeActive.value) + assertTrue(logRepo.logs.value.none { log -> + log.message == "Phantom injected raw monitor packet" || + log.message == "Phantom disco mode started" + }) + assertTrue(scanning.await().isSuccess) + assertEquals(ConnectionState.Scanning, repository.connectionState.value) + } finally { + repository.shutdown() + } + } + + @Test + fun `opposite handle detection publication preserves nested control result`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + logRepo.clearAll() + val enableOnDisabled = async(Dispatchers.Unconfined) { + repository.handleDetection.drop(1).first { detection -> + if (!detection.leftDetected && !detection.rightDetected) { + repository.enableHandleDetection(true) + true + } else { + false + } + } + } + + repository.enableHandleDetection(false) + enableOnDisabled.await() + + assertTrue(repository.handleDetection.value.leftDetected) + assertTrue(repository.handleDetection.value.rightDetected) + assertEquals(HandleState.WaitingForRest, repository.handleState.value) + assertTrue(logRepo.logs.value.any { log -> log.message == "Phantom handle detection enabled" }) + assertTrue(logRepo.logs.value.none { log -> log.message == "Phantom handle detection disabled" }) + } finally { + repository.shutdown() + } + } + + @Test + fun `opposite disco publication preserves nested start and blocks workout handoff`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo, PhantomBleConfig(repDelayMs = 100L)) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + repository.startDiscoMode() + logRepo.clearAll() + val startOnStop = async(Dispatchers.Unconfined) { + repository.discoModeActive.drop(1).first { active -> + if (!active) { + repository.startDiscoMode() + true + } else { + false + } + } + } + + repository.stopDiscoMode() + startOnStop.await() + + assertTrue(repository.discoModeActive.value) + assertTrue(logRepo.logs.value.any { log -> log.message == "Phantom disco mode started" }) + assertTrue(logRepo.logs.value.none { log -> log.message == "Phantom disco mode stopped" }) + + logRepo.clearAll() + val startOnWorkoutStop = async(Dispatchers.Unconfined) { + repository.discoModeActive.drop(1).first { active -> + if (!active) { + repository.startDiscoMode() + true + } else { + false + } + } + } + val workoutResult = repository.startWorkout(workoutParameters()) + startOnWorkoutStop.await() + + assertTrue(workoutResult.isFailure) + assertTrue(repository.discoModeActive.value) + assertTrue(logRepo.logs.value.none { log -> log.message == "Phantom workout started" }) + } finally { + repository.shutdown() + } + } + + private fun workoutParameters(): WorkoutParameters = WorkoutParameters( + programMode = ProgramMode.OldSchool, + reps = 3, + weightPerCableKg = 10f, + ) + + private fun monitorPacket( + ticks: Int, + posA: Int, + velA: Int, + loadA: Int, + posB: Int, + velB: Int, + loadB: Int, + status: Int = 0, + ): ByteArray = ByteArray(18).also { bytes -> + putUInt16LE(bytes, 0, ticks and 0xFFFF) + putUInt16LE(bytes, 2, ticks ushr 16) + putInt16LE(bytes, 4, posA) + putInt16LE(bytes, 6, velA) + putUInt16LE(bytes, 8, loadA) + putInt16LE(bytes, 10, posB) + putInt16LE(bytes, 12, velB) + putUInt16LE(bytes, 14, loadB) + putUInt16LE(bytes, 16, status) + } + + private fun putUInt32LE(bytes: ByteArray, offset: Int, value: Int) { + bytes[offset] = (value and 0xFF).toByte() + bytes[offset + 1] = ((value ushr 8) and 0xFF).toByte() + bytes[offset + 2] = ((value ushr 16) and 0xFF).toByte() + bytes[offset + 3] = ((value ushr 24) and 0xFF).toByte() + } + + private fun putUInt16LE(bytes: ByteArray, offset: Int, value: Int) { + bytes[offset] = (value and 0xFF).toByte() + bytes[offset + 1] = ((value ushr 8) and 0xFF).toByte() + } + + private fun putInt16LE(bytes: ByteArray, offset: Int, value: Int) { + val encoded = value and 0xFFFF + putUInt16LE(bytes, offset, encoded) + } + + private fun putFloatLE(bytes: ByteArray, offset: Int, value: Float) { + val bits = value.toBits() + bytes[offset] = bits.toByte() + bytes[offset + 1] = (bits ushr 8).toByte() + bytes[offset + 2] = (bits ushr 16).toByte() + bytes[offset + 3] = (bits ushr 24).toByte() + } +}