test(reaction): Add ViewModel and reaction logic unit tests

Extract pure decision functions from AutoConnect and PopUpReaction for testability, following the existing PlayPause pattern. Add FakeDataStoreValue test helper.

Fix reversed Duration.between args in PopUpReaction connection monitor that caused the false-positive age filter to never trigger.
This commit is contained in:
darken
2026-03-07 14:19:17 +00:00
committed by Matthias Urhahn
parent da6a6f6ff2
commit 450a187b65
7 changed files with 1199 additions and 65 deletions
@@ -77,25 +77,26 @@ class AutoConnect @Inject constructor(
val condition = reactionSettings.autoConnectCondition.valueBlocking
log(TAG) { "Checking condition $condition" }
val conditionFulfilled = when (condition) {
AutoConnectCondition.WHEN_SEEN -> true
AutoConnectCondition.CASE_OPEN -> when (mainDevice) {
is DualApplePods -> mainDevice.caseLidState == DualApplePods.LidState.OPEN
else -> true
}
AutoConnectCondition.IN_EAR -> when (mainDevice) {
is HasEarDetection -> {
if (mainDevice is HasEarDetectionDual && reactionSettings.onePodMode.valueBlocking) {
mainDevice.isEitherPodInEar
} else {
mainDevice.isBeingWorn
}
}
else -> true
}
}
if (!conditionFulfilled) {
log(TAG) { "Auto connect condition ($condition) is not fullfilled." }
val lidState = (mainDevice as? DualApplePods)?.caseLidState
val isBeingWorn = (mainDevice as? HasEarDetection)?.isBeingWorn ?: false
val isEitherPodInEar = (mainDevice as? HasEarDetectionDual)?.isEitherPodInEar ?: false
val onePodMode = reactionSettings.onePodMode.valueBlocking
val decision = evaluateAutoConnect(
mainDeviceAddr = mainDeviceAddr,
hasBondedDevice = true,
isAlreadyConnected = false,
condition = condition,
lidState = lidState,
isBeingWorn = isBeingWorn,
isEitherPodInEar = isEitherPodInEar,
onePodMode = onePodMode,
supportsEarDetection = mainDevice is HasEarDetection,
)
if (!decision.shouldConnect) {
log(TAG) { "Auto connect condition ($condition) is not fullfilled: ${decision.reason}" }
return@map
}
val result = bluetoothManager.nudgeConnection(bondedDevice)
@@ -103,6 +104,51 @@ class AutoConnect @Inject constructor(
}
.setupCommonEventHandlers(TAG) { "monitor" }
internal fun evaluateAutoConnect(
mainDeviceAddr: String?,
hasBondedDevice: Boolean,
isAlreadyConnected: Boolean,
condition: AutoConnectCondition,
lidState: DualApplePods.LidState?,
isBeingWorn: Boolean,
isEitherPodInEar: Boolean,
onePodMode: Boolean,
supportsEarDetection: Boolean,
): AutoConnectDecision {
if (mainDeviceAddr.isNullOrEmpty()) {
return AutoConnectDecision(false, "No main device address")
}
if (!hasBondedDevice) {
return AutoConnectDecision(false, "No bonded device")
}
if (isAlreadyConnected) {
return AutoConnectDecision(false, "Already connected")
}
return when (condition) {
AutoConnectCondition.WHEN_SEEN -> AutoConnectDecision(true, "WHEN_SEEN: device visible")
AutoConnectCondition.CASE_OPEN -> {
if (lidState == null) {
AutoConnectDecision(true, "CASE_OPEN: unsupported device, permissive fallback")
} else when (lidState) {
DualApplePods.LidState.OPEN -> AutoConnectDecision(true, "CASE_OPEN: lid is open")
else -> AutoConnectDecision(false, "CASE_OPEN: lid is $lidState")
}
}
AutoConnectCondition.IN_EAR -> {
if (!supportsEarDetection) {
return AutoConnectDecision(true, "IN_EAR: unsupported device, permissive fallback")
}
val inEar = if (onePodMode) isEitherPodInEar else isBeingWorn
AutoConnectDecision(inEar, if (inEar) "IN_EAR: pod in ear" else "IN_EAR: not in ear")
}
}
}
data class AutoConnectDecision(
val shouldConnect: Boolean,
val reason: String,
)
companion object {
private val TAG = logTag("Reaction", "AutoConnect")
}
@@ -71,42 +71,32 @@ class PopUpReaction @Inject constructor(
private fun throttleCasePopUps(current: DualApplePods): Event? {
val cooldownKey = current.meta.profile?.id ?: current.identifier.toString()
val now = Instant.now()
val lastShown = caseCoolDowns[cooldownKey]
val decision = evaluateCasePopUp(
currentLidState = current.caseLidState,
lastShownTime = lastShown,
now = now,
)
log(TAG) { "Case popup decision: ${decision.reason}" }
if (decision.shouldResetCooldown) {
caseCoolDowns.remove(cooldownKey)
}
return when {
current.caseLidState == DualApplePods.LidState.OPEN -> {
log(TAG, INFO) { "Show popup" }
val now = Instant.now()
val lastShown = caseCoolDowns[cooldownKey] ?: Instant.MIN
val sinceLastPop = Duration.between(lastShown, now)
log(TAG) { "Time since last case popup: $sinceLastPop" }
if (sinceLastPop >= Duration.ofSeconds(10)) {
caseCoolDowns[cooldownKey] = Instant.now()
Event.PopupShow(device = current)
} else {
log(TAG, INFO) { "Case popup is still on cooldown: $sinceLastPop" }
null
}
decision.shouldShow -> {
caseCoolDowns[cooldownKey] = now
Event.PopupShow(device = current)
}
current.caseLidState != DualApplePods.LidState.OPEN -> {
when (current.caseLidState) {
DualApplePods.LidState.CLOSED -> {
log(TAG, INFO) { "Lid was actively closed, resetting cooldown." }
caseCoolDowns.remove(cooldownKey)
}
else -> {
log(TAG, WARN) { "Lid was was not actively closed, refreshing cooldown." }
caseCoolDowns[cooldownKey] = Instant.now()
}
decision.shouldHide -> {
if (!decision.shouldResetCooldown) {
caseCoolDowns[cooldownKey] = now
}
log(TAG, INFO) { "Hide popup" }
Event.PopupHide()
}
else -> null
}
}
@@ -150,29 +140,26 @@ class PopUpReaction @Inject constructor(
}
if (currentConnected == null || currentBroadcasted == null) {
// We need an active connection
return@mapNotNull null
}
val ageOfBroadcastedDevice = Duration.between(Instant.now(), currentBroadcasted.seenFirstAt)
val ageOfConnectedDevice = Duration.between(Instant.now(), currentConnected.seenFirstAt)
if (ageOfBroadcastedDevice > (ageOfConnectedDevice + Duration.ofSeconds(30))) {
// This is likely a false positive, some random nearby device
// We expect the first broadcasts to not be much older than the first connection
log(TAG, VERBOSE) { "Current broadcasted main device is probably a false-positive" }
return@mapNotNull null
}
val deviceAge = Duration.between(currentBroadcasted.seenFirstAt, Instant.now())
val connectionAge = Duration.between(currentConnected.seenFirstAt, Instant.now())
val now = Instant.now()
val lastShown = connectionCoolDowns[currentConnected.address]
val sinceLastPop = lastShown?.let { Duration.between(it, now) }
log(TAG) { "Time since last connection popup: ${sinceLastPop?.seconds}s" }
val decision = evaluateConnectionPopUp(
hasConnectedDevice = true,
hasPodDevice = true,
hasAlreadyShown = connectionCoolDowns.containsKey(currentConnected.address),
deviceAge = deviceAge,
connectionAge = connectionAge,
)
if (lastShown == null) {
log(TAG) { "Connection popup decision: ${decision.reason}" }
if (decision.shouldShow) {
connectionCoolDowns[currentConnected.address] = Instant.now()
Event.PopupShow(device = currentBroadcasted)
} else {
log(TAG) { "Connection popup is still on cooldown: $sinceLastPop" }
null
}
}
@@ -191,6 +178,89 @@ class PopUpReaction @Inject constructor(
) : Event()
}
internal fun evaluateCasePopUp(
currentLidState: DualApplePods.LidState?,
lastShownTime: Instant?,
now: Instant,
cooldownDuration: Duration = Duration.ofSeconds(10),
): CasePopUpDecision {
if (currentLidState == null) {
return CasePopUpDecision(
shouldShow = false,
shouldHide = false,
shouldResetCooldown = false,
reason = "Non-DualApplePods device",
)
}
return when (currentLidState) {
DualApplePods.LidState.OPEN -> {
val sinceLastPop = lastShownTime?.let { Duration.between(it, now) }
if (sinceLastPop == null || sinceLastPop >= cooldownDuration) {
CasePopUpDecision(
shouldShow = true,
shouldHide = false,
shouldResetCooldown = false,
reason = "Lid OPEN, cooldown expired or first show",
)
} else {
CasePopUpDecision(
shouldShow = false,
shouldHide = false,
shouldResetCooldown = false,
reason = "Lid OPEN, still on cooldown ($sinceLastPop)",
)
}
}
DualApplePods.LidState.CLOSED -> CasePopUpDecision(
shouldShow = false,
shouldHide = true,
shouldResetCooldown = true,
reason = "Lid CLOSED, resetting cooldown",
)
else -> CasePopUpDecision(
shouldShow = false,
shouldHide = true,
shouldResetCooldown = false,
reason = "Lid $currentLidState, refreshing cooldown",
)
}
}
data class CasePopUpDecision(
val shouldShow: Boolean,
val shouldHide: Boolean,
val shouldResetCooldown: Boolean,
val reason: String,
)
internal fun evaluateConnectionPopUp(
hasConnectedDevice: Boolean,
hasPodDevice: Boolean,
hasAlreadyShown: Boolean,
deviceAge: Duration,
connectionAge: Duration,
maxAgeDiff: Duration = Duration.ofSeconds(30),
): ConnectionPopUpDecision {
if (!hasConnectedDevice) {
return ConnectionPopUpDecision(false, "No connected device")
}
if (!hasPodDevice) {
return ConnectionPopUpDecision(false, "No pod device found")
}
if (deviceAge.abs() > (connectionAge.abs() + maxAgeDiff)) {
return ConnectionPopUpDecision(false, "Broadcast too old, likely false positive")
}
if (hasAlreadyShown) {
return ConnectionPopUpDecision(false, "Already shown for this connection")
}
return ConnectionPopUpDecision(true, "New connection detected")
}
data class ConnectionPopUpDecision(
val shouldShow: Boolean,
val reason: String,
)
companion object {
private val TAG = logTag("Reaction", "PopUp")
}